From f1d81f7c6fc6f13454b9599df3dca2722bc5e1f2 Mon Sep 17 00:00:00 2001 From: link1345 Date: Sun, 1 Nov 2026 11:01:46 +0900 Subject: [PATCH 1/5] =?UTF-8?q?=E9=81=A0=E9=9A=94=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E3=81=AE=E3=83=93=E3=83=AB=E3=83=89=20opt-in=20=E3=81=A8=20Pla?= =?UTF-8?q?yer=20=E5=9B=BA=E5=AE=9A=20variant=20=E3=82=92=E5=AE=9F?= =?UTF-8?q?=E8=A3=85=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/gua-release.yml | 43 ++- .github/workflows/nuget-publish.yml | 14 +- .github/workflows/remote-build-variants.yml | 46 ++++ .github/workflows/syntax-check.yml | 18 +- CMakeLists.txt | 15 +- CMakePresets.json | 74 ++++- README.ja.md | 5 + README.md | 7 + bindings/dotnet/NativeVariantPack.targets | 14 + bindings/dotnet/src/Gua.Core/Gua.Core.csproj | 2 + bindings/dotnet/src/Gua.Core/GuaBuildInfo.cs | 31 +++ .../dotnet/src/Gua.Runtime/Gua.Runtime.csproj | 2 + bindings/dotnet/src/Gua.Runtime/GuaRuntime.cs | 6 +- bindings/dotnet/src/Gua.Runtime/Native.cs | 1 + .../Gua.BuildVariants.csproj | 11 + .../dotnet/tests/Gua.BuildVariants/Program.cs | 67 +++++ .../unity/Editor/GuaUnityEditorCommands.cs | 2 + bindings/unity/Editor/GuaVariantBuildGuard.cs | 86 ++++++ .../unity/Editor/GuaVariantBuildGuard.cs.meta | 2 + .../unity/Runtime/GuaUnityCompiledBuild.cs | 20 ++ .../Runtime/GuaUnityCompiledBuild.cs.meta | 2 + bindings/unity/Runtime/GuaUnityGameInput.cs | 2 + bindings/unity/Runtime/GuaUnityRuntime.cs | 23 +- cmake/GuaRemoteBuild.cmake | 59 ++++ cmake/build_config.h.in | 14 + docs/remote-build-variants-evidence.md | 43 +++ docs/remote-build-variants.ja.md | 64 +++++ .../addons/gua/gua_auto_adapter.gd | 9 +- examples/unity-smoke/Assets/csc.rsp | 1 + examples/unity-smoke/Assets/csc.rsp.meta | 2 + native/gua-core/include/gua/gua.h | 7 + native/gua-core/src/gua.cpp | 254 +++++++++++++++--- native/gua-core/src/observe.inc | 4 +- native/gua-core/src/spatial.cpp | 2 + native/gua-core/src/spatial_host.inc | 2 +- native/gua-godot/CMakeLists.txt | 30 ++- .../include/gua/godot/gua_context.hpp | 1 + native/gua-godot/src/gua_context.cpp | 8 + native/gua-godot/src/register_types.cpp | 5 + .../gua-godot/tests/build_variant_fixture.gd | 44 +++ native/gua-runtime/CMakeLists.txt | 13 +- native/gua-runtime/include/gua/runtime.h | 2 + native/gua-runtime/src/runtime.cpp | 32 ++- .../gua-runtime/tests/build_variant_tests.cpp | 96 +++++++ native/gua-ws-bridge/src/ws_bridge.cpp | 6 + protocol/specs/protocol.md | 6 + protocol/specs/remote-build-variants.md | 64 +++++ scripts/build-unity-package.ps1 | 2 + scripts/build-web-native.ps1 | 3 + scripts/prepare-unity-package-ci.ps1 | 3 + scripts/stage-remote-build.ps1 | 98 +++++++ scripts/test-godot-remote-build.ps1 | 80 ++++++ scripts/test-remote-build-config.ps1 | 21 ++ scripts/test-remote-build-variant.ps1 | 49 ++++ scripts/test-remote-package-mixing.ps1 | 43 +++ scripts/verify-native-assets-manifests.ps1 | 25 ++ scripts/verify-remote-build-graph.ps1 | 39 +++ scripts/verify-remote-build.ps1 | 44 +++ scripts/write-native-pair-manifest.ps1 | 23 ++ .../Gua.ArtifactProbe.csproj | 6 + tools/Gua.ArtifactProbe/Program.cs | 43 +++ .../Gua.UnityVariantCompile.csproj | 24 ++ 62 files changed, 1664 insertions(+), 100 deletions(-) create mode 100644 .github/workflows/remote-build-variants.yml create mode 100644 bindings/dotnet/NativeVariantPack.targets create mode 100644 bindings/dotnet/src/Gua.Core/GuaBuildInfo.cs create mode 100644 bindings/dotnet/tests/Gua.BuildVariants/Gua.BuildVariants.csproj create mode 100644 bindings/dotnet/tests/Gua.BuildVariants/Program.cs create mode 100644 bindings/unity/Editor/GuaVariantBuildGuard.cs create mode 100644 bindings/unity/Editor/GuaVariantBuildGuard.cs.meta create mode 100644 bindings/unity/Runtime/GuaUnityCompiledBuild.cs create mode 100644 bindings/unity/Runtime/GuaUnityCompiledBuild.cs.meta create mode 100644 cmake/GuaRemoteBuild.cmake create mode 100644 cmake/build_config.h.in create mode 100644 docs/remote-build-variants-evidence.md create mode 100644 docs/remote-build-variants.ja.md create mode 100644 examples/unity-smoke/Assets/csc.rsp create mode 100644 examples/unity-smoke/Assets/csc.rsp.meta create mode 100644 native/gua-godot/tests/build_variant_fixture.gd create mode 100644 native/gua-runtime/tests/build_variant_tests.cpp create mode 100644 protocol/specs/remote-build-variants.md create mode 100644 scripts/stage-remote-build.ps1 create mode 100644 scripts/test-godot-remote-build.ps1 create mode 100644 scripts/test-remote-build-config.ps1 create mode 100644 scripts/test-remote-build-variant.ps1 create mode 100644 scripts/test-remote-package-mixing.ps1 create mode 100644 scripts/verify-native-assets-manifests.ps1 create mode 100644 scripts/verify-remote-build-graph.ps1 create mode 100644 scripts/verify-remote-build.ps1 create mode 100644 scripts/write-native-pair-manifest.ps1 create mode 100644 tools/Gua.ArtifactProbe/Gua.ArtifactProbe.csproj create mode 100644 tools/Gua.ArtifactProbe/Program.cs create mode 100644 tools/Gua.UnityVariantCompile/Gua.UnityVariantCompile.csproj diff --git a/.github/workflows/gua-release.yml b/.github/workflows/gua-release.yml index dec7665f..8db989a7 100644 --- a/.github/workflows/gua-release.yml +++ b/.github/workflows/gua-release.yml @@ -51,6 +51,10 @@ jobs: scripts/set-inspector-release-version.ps1 -Version '${{ steps.version.outputs.version }}' bun run --filter @gua/inspector tauri:build + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Configure Debug native build run: cmake --preset windows-msvc-debug "-DGUA_VERSION=${{ steps.version.outputs.version }}" "-DGUA_GODOT_PLUGIN_VERSION=${{ steps.version.outputs.version }}" "-DGUA_BUILD_ID=${{ github.sha }}" @@ -116,10 +120,8 @@ jobs: Compress-Archive -Path (Join-Path $godotRoot "addons") -DestinationPath (Join-Path $assets "gua-godot-plugin-windows-v$version.zip") -CompressionLevel Optimal $nativeRoot = Join-Path $env:RUNNER_TEMP "gua-native-win-x64" - New-Item -ItemType Directory -Force $nativeRoot | Out-Null - Copy-Item "build/windows-msvc-release/native/gua-core/Release/gua.dll" $nativeRoot - Copy-Item "build/windows-msvc-release/native/gua-runtime/Release/gua_runtime.dll" $nativeRoot - Copy-Item "LICENSE" $nativeRoot + scripts/stage-remote-build.ps1 -BuildDirectory build/windows-msvc-release -OutputDirectory $nativeRoot -Flavor development -Remote on + "nativeRoot=$nativeRoot" >> $env:GITHUB_OUTPUT Compress-Archive -Path (Join-Path $nativeRoot "*") -DestinationPath (Join-Path $assets "gua-native-win-x64-v$version.zip") -CompressionLevel Optimal Copy-Item "packages/inspector/src-tauri/target/release/bundle" (Join-Path $assets "inspector") -Recurse @@ -137,8 +139,7 @@ jobs: with: name: gua-unity-native-v${{ steps.version.outputs.version }} path: | - build/windows-msvc-release/native/gua-core/Release/gua.dll - build/windows-msvc-release/native/gua-runtime/Release/gua_runtime.dll + ${{ steps.package.outputs.nativeRoot }} if-no-files-found: error - name: Upload Windows Godot GDExtensions @@ -179,8 +180,8 @@ jobs: - name: Configure native and Godot targets run: | - cmake -S . -B build/native -DCMAKE_BUILD_TYPE=Release -DGUA_BUILD_EXAMPLES=OFF -DGUA_GODOT_CONFIGURATION=Release -DGUA_GODOT_ARCHITECTURE=${{ matrix.arch }} "-DGUA_VERSION=${{ needs.windows-assets.outputs.version }}" "-DGUA_GODOT_PLUGIN_VERSION=${{ needs.windows-assets.outputs.version }}" "-DGUA_BUILD_ID=${{ github.sha }}" - cmake -S . -B build/godot-debug -DCMAKE_BUILD_TYPE=Debug -DGUA_BUILD_EXAMPLES=OFF -DGUA_GODOT_CONFIGURATION=Debug -DGUA_GODOT_ARCHITECTURE=${{ matrix.arch }} "-DGUA_VERSION=${{ needs.windows-assets.outputs.version }}" "-DGUA_GODOT_PLUGIN_VERSION=${{ needs.windows-assets.outputs.version }}" "-DGUA_BUILD_ID=${{ github.sha }}" + cmake -S . -B build/native -DCMAKE_BUILD_TYPE=Release -DGUA_BUILD_EXAMPLES=OFF -DGUA_GODOT_CONFIGURATION=Release -DGUA_GODOT_ARCHITECTURE=${{ matrix.arch }} "-DGUA_VERSION=${{ needs.windows-assets.outputs.version }}" "-DGUA_GODOT_PLUGIN_VERSION=${{ needs.windows-assets.outputs.version }}" "-DGUA_BUILD_ID=${{ github.sha }}" -DGUA_DISTRIBUTION_FLAVOR=development -DGUA_ENABLE_REMOTE_AUTOMATION=ON -DGUA_STAGE_IN_SAMPLE=ON + cmake -S . -B build/godot-debug -DCMAKE_BUILD_TYPE=Debug -DGUA_BUILD_EXAMPLES=OFF -DGUA_GODOT_CONFIGURATION=Debug -DGUA_GODOT_ARCHITECTURE=${{ matrix.arch }} "-DGUA_VERSION=${{ needs.windows-assets.outputs.version }}" "-DGUA_GODOT_PLUGIN_VERSION=${{ needs.windows-assets.outputs.version }}" "-DGUA_BUILD_ID=${{ github.sha }}" -DGUA_DISTRIBUTION_FLAVOR=development -DGUA_ENABLE_REMOTE_AUTOMATION=ON -DGUA_STAGE_IN_SAMPLE=ON - name: Build and test release native targets run: | @@ -213,14 +214,15 @@ jobs: otool -l '${{ matrix.runtime-file }}' | grep -A2 LC_RPATH | grep -F '@loader_path' ! otool -L "examples/godot-gdscript/addons/gua/bin/gua_godot.macos.release.${{ matrix.arch }}.dylib" | grep -F 'gua_runtime' + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Package release native archive shell: pwsh run: | $stage = "artifacts/native/${{ matrix.rid }}" - New-Item -ItemType Directory -Force $stage | Out-Null - Copy-Item "${{ matrix.core-file }}" $stage - Copy-Item "${{ matrix.runtime-file }}" $stage - Copy-Item LICENSE $stage + scripts/stage-remote-build.ps1 -BuildDirectory build/native -OutputDirectory $stage -Flavor development -Remote on Compress-Archive -Path "$stage/*" -DestinationPath "artifacts/gua-native-${{ matrix.rid }}-v${{ needs.windows-assets.outputs.version }}.zip" -CompressionLevel Optimal - name: Upload release native archive @@ -281,6 +283,9 @@ jobs: foreach ($contract in $contracts) { $target = "artifacts/native/$($contract.Rid)" New-Item -ItemType Directory -Force $target | Out-Null + $manifest = @(Get-ChildItem $contract.Root -Recurse -File -Filter gua-build-manifest.json) + if ($manifest.Count -ne 1) { throw "Expected one native manifest for $($contract.Rid)." } + Copy-Item $manifest[0].FullName $target foreach ($fileName in @($contract.Core, $contract.Runtime)) { $matches = @(Get-ChildItem $contract.Root -Recurse -File -Filter $fileName) if ($matches.Count -ne 1) { @@ -292,8 +297,8 @@ jobs: - name: Pack native NuGet packages run: | - dotnet pack bindings/dotnet/src/Gua.Core/Gua.Core.csproj --configuration Release -p:GuaPackageVersion=${{ needs.windows-assets.outputs.version }} -p:PackageVersion=${{ needs.windows-assets.outputs.version }} -p:GuaNativeAssetsRoot=${{ github.workspace }}/artifacts/native - dotnet pack bindings/dotnet/src/Gua.Runtime/Gua.Runtime.csproj --configuration Release -p:GuaPackageVersion=${{ needs.windows-assets.outputs.version }} -p:PackageVersion=${{ needs.windows-assets.outputs.version }} -p:GuaNativeAssetsRoot=${{ github.workspace }}/artifacts/native + dotnet pack bindings/dotnet/src/Gua.Core/Gua.Core.csproj --configuration Release -p:GuaPackageVersion=${{ needs.windows-assets.outputs.version }} -p:PackageVersion=${{ needs.windows-assets.outputs.version }} -p:GuaNativeAssetsRoot=${{ github.workspace }}/artifacts/native -p:GuaDistributionFlavor=development -p:GuaEnableRemoteAutomation=true + dotnet pack bindings/dotnet/src/Gua.Runtime/Gua.Runtime.csproj --configuration Release -p:GuaPackageVersion=${{ needs.windows-assets.outputs.version }} -p:PackageVersion=${{ needs.windows-assets.outputs.version }} -p:GuaNativeAssetsRoot=${{ github.workspace }}/artifacts/native -p:GuaDistributionFlavor=development -p:GuaEnableRemoteAutomation=true - name: Verify native package contents shell: pwsh @@ -368,6 +373,10 @@ jobs: pattern: gua-release-native-* path: artifacts/download/portable + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Build Unity native layout and Universal 2 libraries shell: pwsh run: | @@ -391,6 +400,12 @@ jobs: lipo (Join-Path $root "osx-universal/$file") -verify_arch x86_64 arm64 if ($LASTEXITCODE -ne 0) { throw "Universal 2 verification failed for $file." } } + foreach ($layout in @(@{Source='artifacts/download/windows';Rid='win-x64'},@{Source='artifacts/download/portable/gua-release-native-linux-x64';Rid='linux-x64'})) { + $manifest = @(Get-ChildItem $layout.Source -Recurse -File -Filter gua-build-manifest.json) + if ($manifest.Count -ne 1) { throw 'Expected one verified native manifest.' } + Copy-Item $manifest[0].FullName (Join-Path $root $layout.Rid) + } + scripts/write-native-pair-manifest.ps1 -Directory (Join-Path $root 'osx-universal') -Flavor development -Remote on - name: Upload Unity desktop native plugins uses: actions/upload-artifact@v4 diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index c7283c37..8a0b5ffd 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -49,19 +49,21 @@ jobs: run: .github/scripts/release-version-from-tag.ps1 - name: Configure native assets - run: cmake -S . -B build/native -DCMAKE_BUILD_TYPE=Release -DGUA_BUILD_EXAMPLES=OFF -DGUA_BUILD_GODOT=OFF "-DGUA_VERSION=${{ steps.version.outputs.version }}" "-DGUA_BUILD_ID=${{ github.sha }}" + run: cmake -S . -B build/native -DCMAKE_BUILD_TYPE=Release -DGUA_BUILD_EXAMPLES=OFF -DGUA_BUILD_GODOT=OFF "-DGUA_VERSION=${{ steps.version.outputs.version }}" "-DGUA_BUILD_ID=${{ github.sha }}" -DGUA_DISTRIBUTION_FLAVOR=development -DGUA_ENABLE_REMOTE_AUTOMATION=ON -DGUA_STAGE_IN_SAMPLE=ON - name: Build and test native assets run: | cmake --build build/native --config Release --parallel ctest --test-dir build/native -C Release --output-on-failure + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Stage native assets shell: pwsh run: | - New-Item -ItemType Directory -Force "artifacts/native/${{ matrix.rid }}" | Out-Null - Copy-Item "${{ matrix.core-file }}" "artifacts/native/${{ matrix.rid }}/" - Copy-Item "${{ matrix.runtime-file }}" "artifacts/native/${{ matrix.rid }}/" + scripts/stage-remote-build.ps1 -BuildDirectory build/native -OutputDirectory "artifacts/native/${{ matrix.rid }}" -Flavor development -Remote on - name: Upload native assets uses: actions/upload-artifact@v4 @@ -160,7 +162,7 @@ jobs: GUA_NATIVE_DIR: ${{ github.workspace }}\artifacts\native\win-x64 - name: Pack Gua.Core - run: dotnet pack bindings/dotnet/src/Gua.Core/Gua.Core.csproj --configuration Release --no-build -p:GuaPackageVersion=${{ steps.version.outputs.version }} -p:PackageVersion=${{ steps.version.outputs.version }} -p:GuaNativeAssetsRoot=${{ github.workspace }}/artifacts/native -p:RepositoryCommit=${{ github.sha }} + run: dotnet pack bindings/dotnet/src/Gua.Core/Gua.Core.csproj --configuration Release --no-build -p:GuaPackageVersion=${{ steps.version.outputs.version }} -p:PackageVersion=${{ steps.version.outputs.version }} -p:GuaNativeAssetsRoot=${{ github.workspace }}/artifacts/native -p:RepositoryCommit=${{ github.sha }} -p:GuaDistributionFlavor=development -p:GuaEnableRemoteAutomation=true - name: Pack Gua.Testing run: dotnet pack bindings/dotnet/src/Gua.Testing/Gua.Testing.csproj --configuration Release --no-build -p:PackageVersion=${{ steps.version.outputs.version }} -p:GuaPackageVersion=${{ steps.version.outputs.version }} -p:RepositoryCommit=${{ github.sha }} @@ -170,7 +172,7 @@ jobs: - name: Pack Unity runtime and testing host run: | - dotnet pack bindings/dotnet/src/Gua.Runtime/Gua.Runtime.csproj --configuration Release --no-build -p:GuaPackageVersion=${{ steps.version.outputs.version }} -p:PackageVersion=${{ steps.version.outputs.version }} -p:GuaNativeAssetsRoot=${{ github.workspace }}/artifacts/native -p:RepositoryCommit=${{ github.sha }} + dotnet pack bindings/dotnet/src/Gua.Runtime/Gua.Runtime.csproj --configuration Release --no-build -p:GuaPackageVersion=${{ steps.version.outputs.version }} -p:PackageVersion=${{ steps.version.outputs.version }} -p:GuaNativeAssetsRoot=${{ github.workspace }}/artifacts/native -p:RepositoryCommit=${{ github.sha }} -p:GuaDistributionFlavor=development -p:GuaEnableRemoteAutomation=true dotnet pack bindings/dotnet/src/Gua.Testing.Unity/Gua.Testing.Unity.csproj --configuration Release --no-build -p:PackageVersion=${{ steps.version.outputs.version }} -p:GuaPackageVersion=${{ steps.version.outputs.version }} -p:RepositoryCommit=${{ github.sha }} - name: Verify native package contents diff --git a/.github/workflows/remote-build-variants.yml b/.github/workflows/remote-build-variants.yml new file mode 100644 index 00000000..bffebefe --- /dev/null +++ b/.github/workflows/remote-build-variants.yml @@ -0,0 +1,46 @@ +name: Remote build variants +on: + pull_request: + push: + branches: [main] + workflow_dispatch: +permissions: + contents: read +jobs: + windows-variants: + name: Remote variant / ${{ matrix.name }} + runs-on: windows-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - { name: shipping, flavor: shipping, remote: 'off', configuration: Release, godot: true } + - { name: development, flavor: development, remote: 'on', configuration: Debug, godot: true } + - { name: qa, flavor: qa, remote: 'on', configuration: Release, godot: true } + - { name: ai-player, flavor: ai-player, remote: 'on', configuration: Release, godot: true } + - { name: development-local, flavor: development, remote: 'off', configuration: Debug, godot: false } + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + - name: Build and accept the actual variant + shell: pwsh + run: | + $args = @{ Flavor = '${{ matrix.flavor }}'; Remote = '${{ matrix.remote }}'; Configuration = '${{ matrix.configuration }}' } + if ('${{ matrix.godot }}' -eq 'true') { $args.WithGodot = $true } + scripts/test-remote-build-variant.ps1 @args + - name: Preserve build and packaged-consumer evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: remote-${{ matrix.name }}-${{ github.sha }} + path: | + build/remote-*/remote-build-evidence/** + build/remote-*/Testing/Temporary/** + build/remote-*/package-*/** + if-no-files-found: error diff --git a/.github/workflows/syntax-check.yml b/.github/workflows/syntax-check.yml index f4ff8e2a..88461fa1 100644 --- a/.github/workflows/syntax-check.yml +++ b/.github/workflows/syntax-check.yml @@ -69,8 +69,8 @@ jobs: - name: Configure Debug and Release native builds run: | - cmake -S . -B build/godot-debug -DCMAKE_BUILD_TYPE=Debug -DGUA_GODOT_CONFIGURATION=Debug -DGUA_GODOT_ARCHITECTURE=${{ matrix.arch }} -DGUA_BUILD_IMGUI_EXAMPLE=OFF -DGUA_VERSION=0.0.0-ci -DGUA_BUILD_ID=${{ github.sha }} - cmake -S . -B build/godot-release -DCMAKE_BUILD_TYPE=Release -DGUA_GODOT_CONFIGURATION=Release -DGUA_GODOT_ARCHITECTURE=${{ matrix.arch }} -DGUA_BUILD_IMGUI_EXAMPLE=OFF -DGUA_VERSION=0.0.0-ci -DGUA_BUILD_ID=${{ github.sha }} + cmake -S . -B build/godot-debug -DCMAKE_BUILD_TYPE=Debug -DGUA_GODOT_CONFIGURATION=Debug -DGUA_GODOT_ARCHITECTURE=${{ matrix.arch }} -DGUA_BUILD_IMGUI_EXAMPLE=OFF -DGUA_VERSION=0.0.0-ci -DGUA_BUILD_ID=${{ github.sha }} -DGUA_DISTRIBUTION_FLAVOR=development -DGUA_ENABLE_REMOTE_AUTOMATION=ON -DGUA_STAGE_IN_SAMPLE=ON + cmake -S . -B build/godot-release -DCMAKE_BUILD_TYPE=Release -DGUA_GODOT_CONFIGURATION=Release -DGUA_GODOT_ARCHITECTURE=${{ matrix.arch }} -DGUA_BUILD_IMGUI_EXAMPLE=OFF -DGUA_VERSION=0.0.0-ci -DGUA_BUILD_ID=${{ github.sha }} -DGUA_DISTRIBUTION_FLAVOR=development -DGUA_ENABLE_REMOTE_AUTOMATION=ON -DGUA_STAGE_IN_SAMPLE=ON - name: Build Debug and Release Godot GDExtensions run: | @@ -291,7 +291,7 @@ jobs: bun run --filter @gua/inspector build:trace - name: Configure portable native targets - run: cmake -S . -B build/portable -DCMAKE_BUILD_TYPE=Release -DGUA_BUILD_GODOT=OFF -DGUA_BUILD_IMGUI_EXAMPLE=OFF "-DGUA_VERSION=0.0.0-ci" "-DGUA_BUILD_ID=${{ github.sha }}" + run: cmake -S . -B build/portable -DCMAKE_BUILD_TYPE=Release -DGUA_BUILD_GODOT=OFF -DGUA_BUILD_IMGUI_EXAMPLE=OFF "-DGUA_VERSION=0.0.0-ci" "-DGUA_BUILD_ID=${{ github.sha }}" -DGUA_DISTRIBUTION_FLAVOR=development -DGUA_ENABLE_REMOTE_AUTOMATION=ON -DGUA_STAGE_IN_SAMPLE=ON - name: Build portable native targets run: | @@ -321,9 +321,7 @@ jobs: - name: Stage native package assets shell: pwsh run: | - New-Item -ItemType Directory -Force "artifacts/native/${{ matrix.rid }}" | Out-Null - Copy-Item "${{ matrix.core-file }}" "artifacts/native/${{ matrix.rid }}/" - Copy-Item "${{ matrix.runtime-file }}" "artifacts/native/${{ matrix.rid }}/" + scripts/stage-remote-build.ps1 -BuildDirectory build/portable -OutputDirectory "artifacts/native/${{ matrix.rid }}" -Flavor development -Remote on - name: Upload native package assets uses: actions/upload-artifact@v4 @@ -363,8 +361,8 @@ jobs: - name: Pack native packages run: | - dotnet pack bindings/dotnet/src/Gua.Core/Gua.Core.csproj --configuration Release -p:GuaPackageVersion=0.0.0-ci -p:PackageVersion=0.0.0-ci -p:GuaNativeAssetsRoot=${{ github.workspace }}/artifacts/native -p:RepositoryCommit=${{ github.sha }} - dotnet pack bindings/dotnet/src/Gua.Runtime/Gua.Runtime.csproj --configuration Release -p:GuaPackageVersion=0.0.0-ci -p:PackageVersion=0.0.0-ci -p:GuaNativeAssetsRoot=${{ github.workspace }}/artifacts/native -p:RepositoryCommit=${{ github.sha }} + dotnet pack bindings/dotnet/src/Gua.Core/Gua.Core.csproj --configuration Release -p:GuaPackageVersion=0.0.0-ci -p:PackageVersion=0.0.0-ci -p:GuaNativeAssetsRoot=${{ github.workspace }}/artifacts/native -p:RepositoryCommit=${{ github.sha }} -p:GuaDistributionFlavor=development -p:GuaEnableRemoteAutomation=true + dotnet pack bindings/dotnet/src/Gua.Runtime/Gua.Runtime.csproj --configuration Release -p:GuaPackageVersion=0.0.0-ci -p:PackageVersion=0.0.0-ci -p:GuaNativeAssetsRoot=${{ github.workspace }}/artifacts/native -p:RepositoryCommit=${{ github.sha }} -p:GuaDistributionFlavor=development -p:GuaEnableRemoteAutomation=true - name: Set up Bun for the packaged Trace Viewer uses: oven-sh/setup-bun@v2 @@ -493,7 +491,7 @@ jobs: uses: actions/checkout@v4 - name: Configure - run: cmake -S . -B build/cpp -DCMAKE_BUILD_TYPE=Debug -DGUA_BUILD_GODOT=OFF + run: cmake -S . -B build/cpp -DCMAKE_BUILD_TYPE=Debug -DGUA_BUILD_GODOT=OFF -DGUA_DISTRIBUTION_FLAVOR=development -DGUA_ENABLE_REMOTE_AUTOMATION=ON -DGUA_STAGE_IN_SAMPLE=ON - name: Build run: | @@ -522,7 +520,7 @@ jobs: - name: Build native runtime for managed clock tests run: | - cmake -S . -B build/dotnet-native -DCMAKE_BUILD_TYPE=Release -DGUA_BUILD_EXAMPLES=OFF -DGUA_BUILD_GODOT=OFF + cmake -S . -B build/dotnet-native -DCMAKE_BUILD_TYPE=Release -DGUA_BUILD_EXAMPLES=OFF -DGUA_BUILD_GODOT=OFF -DGUA_DISTRIBUTION_FLAVOR=development -DGUA_ENABLE_REMOTE_AUTOMATION=ON -DGUA_STAGE_IN_SAMPLE=ON cmake --build build/dotnet-native --config Release --target gua gua-runtime --parallel - name: Restore diff --git a/CMakeLists.txt b/CMakeLists.txt index f2c8108e..b1de9516 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,6 +2,8 @@ cmake_minimum_required(VERSION 3.20) project(gua LANGUAGES C CXX) +include(cmake/GuaRemoteBuild.cmake) + set(GUA_VERSION "0.5.0-preview.3" CACHE STRING "Gua component version") set(GUA_BUILD_ID "development" CACHE STRING "Reproducible Gua build identifier") set(GUA_GODOT_PLUGIN_VERSION "0.4.0" CACHE STRING "Gua Godot plugin version") @@ -30,12 +32,19 @@ else() set(GUA_DESKTOP_NATIVE OFF) endif() -if(GUA_DESKTOP_NATIVE AND NOT EMSCRIPTEN) +if(GUA_ENABLE_REMOTE_AUTOMATION AND GUA_DESKTOP_NATIVE AND NOT EMSCRIPTEN) set(GUA_BUILD_WS_BRIDGE_DEFAULT ON) else() set(GUA_BUILD_WS_BRIDGE_DEFAULT OFF) endif() option(GUA_BUILD_WS_BRIDGE "Build the native Inspector WebSocket transport." ${GUA_BUILD_WS_BRIDGE_DEFAULT}) +if(GUA_BUILD_WS_BRIDGE AND NOT GUA_ENABLE_REMOTE_AUTOMATION) + message(FATAL_ERROR "GUA_BUILD_WS_BRIDGE requires the explicit remote automation opt-in") +endif() +if(GUA_ENABLE_REMOTE_AUTOMATION AND GUA_DESKTOP_NATIVE AND NOT EMSCRIPTEN AND NOT GUA_BUILD_WS_BRIDGE) + message(FATAL_ERROR "The selected native remote variant requires its WebSocket transport; use a remote-OFF development build for local-only use") +endif() +gua_finalize_remote_build() add_subdirectory(native/gua-core) add_subdirectory(native/gua-imgui) @@ -54,12 +63,12 @@ elseif(GUA_BUILD_GODOT) endif() if(GUA_BUILD_EXAMPLES) - if(WIN32 AND GUA_BUILD_IMGUI_EXAMPLE) + if(WIN32 AND GUA_BUILD_IMGUI_EXAMPLE AND TARGET gua-ws-bridge AND GUA_DISTRIBUTION_FLAVOR MATCHES "^(development|qa)$") add_subdirectory(examples/cpp-imgui) elseif(NOT WIN32) message(STATUS "Skipping examples/cpp-imgui because it requires Win32 and DirectX 11.") endif() - if(TARGET gua-ws-bridge) + if(TARGET gua-ws-bridge AND GUA_DISTRIBUTION_FLAVOR MATCHES "^(development|qa)$") add_subdirectory(examples/native-bridge) else() message(STATUS "Skipping examples/native-bridge because the native WebSocket bridge is unavailable.") diff --git a/CMakePresets.json b/CMakePresets.json index ab60608d..45ef9340 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -14,7 +14,10 @@ "architecture": "x64", "binaryDir": "${sourceDir}/build/windows-msvc-debug", "cacheVariables": { - "GUA_GODOT_CONFIGURATION": "Debug" + "GUA_GODOT_CONFIGURATION": "Debug", + "GUA_DISTRIBUTION_FLAVOR": "development", + "GUA_ENABLE_REMOTE_AUTOMATION": true, + "GUA_STAGE_IN_SAMPLE": true }, "condition": { "type": "equals", @@ -30,6 +33,60 @@ "architecture": "x64", "binaryDir": "${sourceDir}/build/windows-msvc-release", "cacheVariables": { + "GUA_GODOT_CONFIGURATION": "Release", + "GUA_DISTRIBUTION_FLAVOR": "development", + "GUA_ENABLE_REMOTE_AUTOMATION": true, + "GUA_STAGE_IN_SAMPLE": true + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + } + }, + { + "name": "windows-shipping", + "displayName": "Windows shipping", + "generator": "Visual Studio 18 2026", + "architecture": "x64", + "binaryDir": "${sourceDir}/build/windows-shipping", + "cacheVariables": { + "GUA_DISTRIBUTION_FLAVOR": "shipping", + "GUA_ENABLE_REMOTE_AUTOMATION": false, + "GUA_GODOT_CONFIGURATION": "Release" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + } + }, + { + "name": "windows-qa", + "displayName": "Windows qa", + "generator": "Visual Studio 18 2026", + "architecture": "x64", + "binaryDir": "${sourceDir}/build/windows-qa", + "cacheVariables": { + "GUA_DISTRIBUTION_FLAVOR": "qa", + "GUA_ENABLE_REMOTE_AUTOMATION": true, + "GUA_GODOT_CONFIGURATION": "Release" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + } + }, + { + "name": "windows-ai-player", + "displayName": "Windows ai-player", + "generator": "Visual Studio 18 2026", + "architecture": "x64", + "binaryDir": "${sourceDir}/build/windows-ai-player", + "cacheVariables": { + "GUA_DISTRIBUTION_FLAVOR": "ai-player", + "GUA_ENABLE_REMOTE_AUTOMATION": true, "GUA_GODOT_CONFIGURATION": "Release" }, "condition": { @@ -49,6 +106,21 @@ "name": "windows-msvc-release", "configurePreset": "windows-msvc-release", "configuration": "Release" + }, + { + "name": "windows-shipping", + "configurePreset": "windows-shipping", + "configuration": "Release" + }, + { + "name": "windows-qa", + "configurePreset": "windows-qa", + "configuration": "Release" + }, + { + "name": "windows-ai-player", + "configurePreset": "windows-ai-player", + "configuration": "Release" } ] } diff --git a/README.ja.md b/README.ja.md index 1890aafb..ef09625f 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,5 +1,10 @@ # Gua +遠隔機能はビルド時の明示 opt-in になった。ソースの既定は遠隔実装なしの通常出荷版。 +Development/Editor、QA Release、Player 上限固定の AI Player 出荷版を分ける。 +既存 `windows-msvc-*` サンプル preset は Development を明示した検証用であり、通常出荷用ではない。 +[ビルド・配布ガイド](docs/remote-build-variants.ja.md)と[規範仕様](protocol/specs/remote-build-variants.md)を参照。 + ## Main [English](README.md) | 日本語 diff --git a/README.md b/README.md index ecc0c731..2ac5d36c 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,13 @@ player. **Build → Run → Inspect → Test → Fix → Test again.** +Remote automation is an explicit build opt-in. Source builds default to a +remote-free Shipping variant; Development/Editor, QA Release and Player-only +AI-player distribution use separate variants. Existing `windows-msvc-*` sample +presets explicitly select Development and are not Shipping artifacts. See +[the build contract](protocol/specs/remote-build-variants.md) and +[distribution guide](docs/remote-build-variants.ja.md). + Gua starts with the Playwright-like model: the browser DOM becomes a Semantic UI Tree, and locators, actions, waits, and assertions operate against the live game runtime. It then extends that model for game development with game-world diff --git a/bindings/dotnet/NativeVariantPack.targets b/bindings/dotnet/NativeVariantPack.targets new file mode 100644 index 00000000..2d293adb --- /dev/null +++ b/bindings/dotnet/NativeVariantPack.targets @@ -0,0 +1,14 @@ + + + shipping + false + + + + + + + + + diff --git a/bindings/dotnet/src/Gua.Core/Gua.Core.csproj b/bindings/dotnet/src/Gua.Core/Gua.Core.csproj index 7d4b5235..12a47ec2 100644 --- a/bindings/dotnet/src/Gua.Core/Gua.Core.csproj +++ b/bindings/dotnet/src/Gua.Core/Gua.Core.csproj @@ -50,4 +50,6 @@ Condition="'$(GuaNativeAssetsRoot)' == '' And !Exists('..\..\..\..\build\windows-msvc-release\native\gua-core\Release\gua.dll')"> + core + diff --git a/bindings/dotnet/src/Gua.Core/GuaBuildInfo.cs b/bindings/dotnet/src/Gua.Core/GuaBuildInfo.cs new file mode 100644 index 00000000..1afbb8b2 --- /dev/null +++ b/bindings/dotnet/src/Gua.Core/GuaBuildInfo.cs @@ -0,0 +1,31 @@ +using System.Runtime.InteropServices; +using System.Text; + +namespace Gua.Core; + +/// Immutable native build availability; does not grant session permissions. +public static class GuaBuildInfo +{ + public static unsafe string GetJson() + { + int size = Native.gua_copy_build_info_json(null, 0); + if (size <= 0 || size > 65536) throw new InvalidOperationException("Invalid Gua build identity."); + var bytes = new byte[size]; + fixed (byte* buffer = bytes) { + if (Native.gua_copy_build_info_json(buffer, size) != size) + throw new InvalidOperationException("Unstable Gua build identity."); + } + return Encoding.UTF8.GetString(bytes, 0, size - 1); + } +} + +internal static partial class Native +{ +#if GUA_STATIC_LINK + private const string BuildInfoLibrary = "__Internal"; +#else + private const string BuildInfoLibrary = "gua"; +#endif + [DllImport(BuildInfoLibrary, CallingConvention = CallingConvention.Cdecl)] + internal static extern unsafe int gua_copy_build_info_json(byte* output, int size); +} diff --git a/bindings/dotnet/src/Gua.Runtime/Gua.Runtime.csproj b/bindings/dotnet/src/Gua.Runtime/Gua.Runtime.csproj index 2f2890ed..77132b58 100644 --- a/bindings/dotnet/src/Gua.Runtime/Gua.Runtime.csproj +++ b/bindings/dotnet/src/Gua.Runtime/Gua.Runtime.csproj @@ -42,4 +42,6 @@ Condition="'$(GuaNativeAssetsRoot)' == '' And !Exists('..\..\..\..\build\windows-msvc-release\native\gua-runtime\Release\gua_runtime.dll')"> + runtime + diff --git a/bindings/dotnet/src/Gua.Runtime/GuaRuntime.cs b/bindings/dotnet/src/Gua.Runtime/GuaRuntime.cs index aee57ff3..618fe0c7 100644 --- a/bindings/dotnet/src/Gua.Runtime/GuaRuntime.cs +++ b/bindings/dotnet/src/Gua.Runtime/GuaRuntime.cs @@ -22,6 +22,8 @@ public sealed partial class GuaRuntime : IDisposable public GuaRuntime() { try { + if (CopyJson(0, JsonSource.BuildInfo) != GuaBuildInfo.GetJson()) + throw new InvalidOperationException("Gua core/runtime build variants do not match."); _handle = Native.gua_runtime_create(); if (_handle == 0) throw new InvalidOperationException("Failed to create a Gua runtime."); _observations = new GuaContext(Native.gua_runtime_borrow_context(_handle)); @@ -255,6 +257,7 @@ public bool TryCompleteScreenshot(GuaScreenshotRequest request, GuaScreenshotAva public void AddLog(int level, string message) { ThrowIfDisposed(); Native.gua_runtime_add_log(_handle, level, message); } public string GetUiTreeJson() { ThrowIfDisposed(); return CopyUiTree(_handle); } public string GetPlayerUiTreeJson() { ThrowIfDisposed(); return CopyJson(_handle, JsonSource.PlayerUiTree); } + public string GetBuildInfoJson() { ThrowIfDisposed(); return CopyJson(_handle, JsonSource.BuildInfo); } public string GetVersionJson() { ThrowIfDisposed(); return CopyVersion(_handle); } /// Read-only, redacted Debug diagnostics, including the bounded native lifecycle journal. public string GetDiagnosticsJson() { ThrowIfDisposed(); return Observations.GetDiagnosticsJson(); } @@ -302,8 +305,9 @@ private static unsafe string CopyJson(nint handle, JsonSource source) { JsonSource.UiTree => Native.gua_runtime_copy_ui_tree_json(handle, buffer, bufferSize), JsonSource.PlayerUiTree => Native.gua_runtime_copy_player_ui_tree_json(handle, buffer, bufferSize), + JsonSource.BuildInfo => Native.gua_runtime_copy_build_info_json(buffer, bufferSize), JsonSource.Version => Native.gua_runtime_copy_version_json(handle, buffer, bufferSize), _ => throw new ArgumentOutOfRangeException(nameof(source), source, null), }; - private enum JsonSource { UiTree, PlayerUiTree, Version } + private enum JsonSource { UiTree, PlayerUiTree, Version, BuildInfo } } diff --git a/bindings/dotnet/src/Gua.Runtime/Native.cs b/bindings/dotnet/src/Gua.Runtime/Native.cs index aceb0561..67c6d444 100644 --- a/bindings/dotnet/src/Gua.Runtime/Native.cs +++ b/bindings/dotnet/src/Gua.Runtime/Native.cs @@ -62,6 +62,7 @@ private static IEnumerable CandidateDirectories(Assembly assembly) #else private const string Library = "gua_runtime"; #endif + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] internal static extern unsafe int gua_runtime_copy_build_info_json(byte* output, int size); [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] internal static extern nint gua_runtime_create(); [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] internal static extern void gua_runtime_destroy(nint runtime); [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] internal static extern void gua_runtime_begin_frame(nint runtime, [MarshalAs(UnmanagedType.LPUTF8Str)] string screen); diff --git a/bindings/dotnet/tests/Gua.BuildVariants/Gua.BuildVariants.csproj b/bindings/dotnet/tests/Gua.BuildVariants/Gua.BuildVariants.csproj new file mode 100644 index 00000000..4a9ba86c --- /dev/null +++ b/bindings/dotnet/tests/Gua.BuildVariants/Gua.BuildVariants.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + enable + + + + + diff --git a/bindings/dotnet/tests/Gua.BuildVariants/Program.cs b/bindings/dotnet/tests/Gua.BuildVariants/Program.cs new file mode 100644 index 00000000..2d47be4b --- /dev/null +++ b/bindings/dotnet/tests/Gua.BuildVariants/Program.cs @@ -0,0 +1,67 @@ +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using Gua.Core; +using Gua.Runtime; + +static void Check(bool condition, string reason) { if (!condition) throw new InvalidOperationException(reason); } +Check(args.Length == 4, "core directory, runtime directory, expected flavor, expected remote on/off are required"); +Environment.SetEnvironmentVariable("GUA_NATIVE_DIR", Path.GetFullPath(args[0])); +Environment.SetEnvironmentVariable("GUA_RUNTIME_NATIVE_DIR", Path.GetFullPath(args[1])); +Environment.SetEnvironmentVariable("GUA_OBSERVATION_PROFILE", null); +bool remote=args[3]=="on", player=args[2]=="ai-player"; +using var runtime=new GuaRuntime(); +using var identity=JsonDocument.Parse(runtime.GetBuildInfoJson()); +Check(identity.RootElement.GetProperty("flavor").GetString()==args[2],"wrong flavor"); +Check(identity.RootElement.GetProperty("remoteEnabled").GetBoolean()==remote,"wrong compiled availability"); +Check(runtime.GetBuildInfoJson()==GuaBuildInfo.GetJson(),"core/runtime mismatch"); +runtime.BeginFrame("wire"); +runtime.RegisterNode(new("public","button","Public",new(0,0,10,10))); +runtime.RegisterNode(new("secret","button","Hidden secret",new(0,0,10,10),AgentPolicy:new(GuaAgentExposure.Private))); +runtime.EndFrame(); +if (player) { + Check(runtime.ObservationProfile==GuaObservationProfile.Player,"AI Player not initially Player"); + bool rejected=false; + try { runtime.SetObservationProfile(GuaObservationProfile.Debug); } catch(InvalidOperationException) { rejected=true; } + Check(rejected,"managed API elevated AI Player"); + Check(!runtime.GetUiTreeJson().Contains("Hidden secret"),"managed legacy tree leaked secret"); +} +const int port=19462; +Check(runtime.StartInspectorBridge(port)==remote,"bridge availability mismatch"); +if(!remote) { Check(!runtime.InspectorBridgeRunning && runtime.InspectorBridgeUrl=="","Shipping bridge is active"); return; } +using var deadline=new CancellationTokenSource(TimeSpan.FromSeconds(15)); +using var socket=new ClientWebSocket(); +await socket.ConnectAsync(new Uri($"ws://127.0.0.1:{port}"),deadline.Token); +int nextId=0; +async Task Command(string type, object? extra=null) { + int id=++nextId; + var request=new Dictionary { ["id"]=id,["type"]=type }; + if(extra!=null) foreach(var property in JsonSerializer.SerializeToElement(extra).EnumerateObject()) request[property.Name]=property.Value; + await socket.SendAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(request)),WebSocketMessageType.Text,true,deadline.Token); + while(true) { + using var message=new MemoryStream(); + WebSocketReceiveResult received; + do { var buffer=new byte[8192]; received=await socket.ReceiveAsync(new ArraySegment(buffer),deadline.Token); message.Write(buffer,0,received.Count); } while(!received.EndOfMessage); + using var document=JsonDocument.Parse(message.ToArray()); + if(document.RootElement.TryGetProperty("id",out var responseId) && responseId.ValueKind==JsonValueKind.Number && responseId.GetInt32()==id) return document.RootElement.Clone(); + } +} +var tree=await Command("get_ui_tree"); +Check(tree.GetProperty("ok").GetBoolean(),"wire tree failed"); +Check(tree.GetRawText().Contains("Public"),"public tree missing"); +Check(tree.GetRawText().Contains("Hidden secret")!=player,"wire projection incorrect"); +runtime.EnableVirtualClockAdapter(); +var version=await Command("get_version"); +Check(version.GetProperty("ok").GetBoolean(),"version failed"); +Check(version.GetRawText().Contains("virtual_clock_v1")!=player,"clock advertisement ignores compiled ceiling"); +var clock=await Command("clock_install",new {initialTimeMs=0,stepMs=10}); +Check(clock.GetProperty("ok").GetBoolean()!=player,"privileged clock dispatch violates variant"); +var secret=await Command("click_node",new {nodeId="secret"}); +Check(secret.GetProperty("ok").GetBoolean()!=player,"private UI authorization incorrect"); +var click=await Command("click_node",new {nodeId="public"}); +Check(click.GetProperty("ok").GetBoolean(),"public click not accepted"); +Check(runtime.TryConsumeAction(GuaActionType.Click,"public",out var action),"public click did not reach host"); +runtime.EmitActionResult(action,true); +socket.Abort(); +runtime.StopInspectorBridge(); +Console.WriteLine($"{args[2]} managed + real WebSocket acceptance passed"); diff --git a/bindings/unity/Editor/GuaUnityEditorCommands.cs b/bindings/unity/Editor/GuaUnityEditorCommands.cs index 6d12da0a..c20411ae 100644 --- a/bindings/unity/Editor/GuaUnityEditorCommands.cs +++ b/bindings/unity/Editor/GuaUnityEditorCommands.cs @@ -19,6 +19,8 @@ public static void PrepareProject() public static void BuildPlayer() { + // This command builds the repository's development fixture, not a shipping game. + Environment.SetEnvironmentVariable("GUA_BUILD_VARIANT", Argument("-guaVariant") ?? "development"); PrepareProject(); var scene = Argument("-guaScene") ?? throw new ArgumentException("-guaScene is required."); var output = Argument("-guaOutput") ?? throw new ArgumentException("-guaOutput is required."); diff --git a/bindings/unity/Editor/GuaVariantBuildGuard.cs b/bindings/unity/Editor/GuaVariantBuildGuard.cs new file mode 100644 index 00000000..6623acb7 --- /dev/null +++ b/bindings/unity/Editor/GuaVariantBuildGuard.cs @@ -0,0 +1,86 @@ +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text.Json; +using Gua.Core; +using Gua.Runtime; +using UnityEditor; +using UnityEditor.Build; +using UnityEditor.Build.Reporting; +using UnityEditor.PackageManager; + +namespace Gua.Unity.Editor +{ +/// Checks the selected artifacts; build intent cannot rewrite native capabilities. +public sealed class GuaVariantBuildGuard : IPreprocessBuildWithReport +{ + public int callbackOrder => -10000; + + public void OnPreprocessBuild(BuildReport report) + { + string expected = Environment.GetEnvironmentVariable("GUA_BUILD_VARIANT") ?? "shipping"; + if (expected != "shipping" && expected != "development" && expected != "qa" && expected != "ai-player") + throw new BuildFailedException("Unknown GUA_BUILD_VARIANT build purpose."); + bool release = (report.summary.options & BuildOptions.Development) == 0; + if (expected != "development" && !release) + throw new BuildFailedException("Shipping, QA and AI Player require a Release Player build."); + try + { + using var loaded = JsonDocument.Parse(GuaBuildInfo.GetJson()); + ValidateIdentity(loaded.RootElement, expected); + using var runtime = new GuaRuntime(); + if (runtime.GetBuildInfoJson() != GuaBuildInfo.GetJson()) throw new InvalidDataException("Loaded core/runtime build mismatch."); + int selectedCore = 0, selectedRuntime = 0; + foreach (var importer in PluginImporter.GetAllImporters()) + { + if (!importer.GetCompatibleWithPlatform(report.summary.platform)) continue; + string name = Path.GetFileName(importer.assetPath); + if (name != "gua.dll" && name != "gua_runtime.dll" && name != "libgua.so" && name != "libgua_runtime.so" && name != "libgua.dylib" && name != "libgua_runtime.dylib") continue; + string file = ResolveAsset(importer.assetPath); + string manifestPath = Path.Combine(Path.GetDirectoryName(file)!, "gua-build-manifest.json"); + using var manifest = JsonDocument.Parse(File.ReadAllText(manifestPath)); + ValidateIdentity(manifest.RootElement.GetProperty("build"), expected); + var fileBuild = manifest.RootElement.GetProperty("build"); + if (fileBuild.GetProperty("sourceCommit").GetString() != loaded.RootElement.GetProperty("sourceCommit").GetString() || + fileBuild.GetProperty("buildId").GetString() != loaded.RootElement.GetProperty("buildId").GetString()) + throw new InvalidDataException("Imported native assets came from different builds."); + var entries = manifest.RootElement.GetProperty("files").EnumerateArray().Where(e => e.GetProperty("path").GetString() == name).ToArray(); + if (entries.Length != 1) throw new InvalidDataException("Missing or duplicate native file identity."); + using var hash = SHA256.Create(); + using var stream = File.OpenRead(file); + string actual = BitConverter.ToString(hash.ComputeHash(stream)).Replace("-", "").ToLowerInvariant(); + if (actual != entries[0].GetProperty("sha256").GetString()) throw new InvalidDataException("Native file differs from its verified build."); + if (name.Contains("runtime")) selectedRuntime++; else selectedCore++; + } + if (selectedCore != 1 || selectedRuntime != 1) throw new InvalidDataException("The Player needs exactly one verified Gua core/runtime pair."); + } + catch (Exception error) + { + throw new BuildFailedException("Gua build variant validation failed before Player generation: " + error.Message); + } + } + + private static void ValidateIdentity(JsonElement info, string expected) + { + string ceiling = info.GetProperty("remotePermissionCeiling").GetString()!; + bool remote = info.GetProperty("remoteEnabled").GetBoolean(); + if (info.GetProperty("flavor").GetString() != expected || + (expected != "development" && info.GetProperty("configuration").GetString() != "Release") || + (expected == "shipping" && remote) || (expected != "shipping" && expected != "development" && !remote) || + ceiling != (!remote ? "none" : expected == "ai-player" ? "player" : "debug") || + remote != GuaUnityCompiledBuild.RemoteEnabled || (ceiling == "player") != GuaUnityCompiledBuild.PlayerOnly) + throw new InvalidDataException("Native/managed build purpose or compiled ceiling mismatch."); + } + + private static string ResolveAsset(string asset) + { + if (asset.StartsWith("Packages/", StringComparison.Ordinal)) + { + var package = PackageInfo.FindForAssetPath(asset) ?? throw new InvalidDataException("Cannot resolve Gua package."); + return Path.Combine(package.resolvedPath, asset.Substring(("Packages/" + package.name + "/").Length)); + } + return Path.GetFullPath(asset); + } +} +} diff --git a/bindings/unity/Editor/GuaVariantBuildGuard.cs.meta b/bindings/unity/Editor/GuaVariantBuildGuard.cs.meta new file mode 100644 index 00000000..570137df --- /dev/null +++ b/bindings/unity/Editor/GuaVariantBuildGuard.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1c69fb908f714b598bc0e0b2af535ad7 diff --git a/bindings/unity/Runtime/GuaUnityCompiledBuild.cs b/bindings/unity/Runtime/GuaUnityCompiledBuild.cs new file mode 100644 index 00000000..edf64d54 --- /dev/null +++ b/bindings/unity/Runtime/GuaUnityCompiledBuild.cs @@ -0,0 +1,20 @@ +namespace Gua.Unity +{ +/// Compile-time managed variant, independently checked against native identity. +public static class GuaUnityCompiledBuild +{ +#if GUA_REMOTE_AUTOMATION + public const bool RemoteEnabled = true; +#else + public const bool RemoteEnabled = false; +#endif +#if GUA_PLAYER_ONLY + public const bool PlayerOnly = true; +#if !GUA_REMOTE_AUTOMATION +#error GUA_PLAYER_ONLY requires GUA_REMOTE_AUTOMATION +#endif +#else + public const bool PlayerOnly = false; +#endif +} +} diff --git a/bindings/unity/Runtime/GuaUnityCompiledBuild.cs.meta b/bindings/unity/Runtime/GuaUnityCompiledBuild.cs.meta new file mode 100644 index 00000000..1f2bf01f --- /dev/null +++ b/bindings/unity/Runtime/GuaUnityCompiledBuild.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c39eab92b3cb4d8b82c77769b9af1325 diff --git a/bindings/unity/Runtime/GuaUnityGameInput.cs b/bindings/unity/Runtime/GuaUnityGameInput.cs index 67030fb9..6652c143 100644 --- a/bindings/unity/Runtime/GuaUnityGameInput.cs +++ b/bindings/unity/Runtime/GuaUnityGameInput.cs @@ -49,7 +49,9 @@ private void InitializeGameInput() if (runtime == null) return; var map = FindGameInputMapInActiveScene(); if (hasPublishedGameInputMap && ReferenceEquals(map, publishedGameInputMap)) { gameInputMapRefreshPending = false; return; } +#if GUA_REMOTE_AUTOMATION ReleaseWebGameInputSession(); +#endif DisposeGameInput(); if (map == null) { diff --git a/bindings/unity/Runtime/GuaUnityRuntime.cs b/bindings/unity/Runtime/GuaUnityRuntime.cs index 1b010bb0..a1229025 100644 --- a/bindings/unity/Runtime/GuaUnityRuntime.cs +++ b/bindings/unity/Runtime/GuaUnityRuntime.cs @@ -36,6 +36,7 @@ public sealed partial class GuaUnityRuntime : MonoBehaviour private object? frameFocusTarget; private bool screenshotRunning; private static GuaUnityRuntime activeRuntime; +#if GUA_REMOTE_AUTOMATION private readonly Dictionary webCalls = new(); private readonly Dictionary webGameInputCalls = new(); private GuaGameInputSession? webGameInputSession; @@ -47,6 +48,8 @@ public sealed partial class GuaUnityRuntime : MonoBehaviour [DllImport("__Internal")] private static extern void GuaUnityWebUninstall(string ownerId); [DllImport("__Internal")] private static extern void GuaUnityWebResolve(string ownerId, int callId, string json, int failed); +#endif + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] public static void EnsureStarted() { @@ -69,7 +72,14 @@ private void Awake() SceneManager.activeSceneChanged += HandleGameInputActiveSceneChanged; InitializeGameInput(); runtime.EnableWorldObjectTreeAdapter(); - if (Application.platform == RuntimePlatform.WebGLPlayer) + using (var build = JsonDocument.Parse(runtime.GetBuildInfoJson())) { + if (build.RootElement.GetProperty("remoteEnabled").GetBoolean() != GuaUnityCompiledBuild.RemoteEnabled || + (build.RootElement.GetProperty("remotePermissionCeiling").GetString() == "player") != GuaUnityCompiledBuild.PlayerOnly) + throw new InvalidOperationException("Gua Unity managed/native build variants do not match."); + } +#if GUA_REMOTE_AUTOMATION + bool remoteEnabled = true; + if (remoteEnabled && Application.platform == RuntimePlatform.WebGLPlayer) { webObserveClient = runtime.CreateObserveClient(GuaObservationProfile.Player); webOwnerId = Guid.NewGuid().ToString("N"); @@ -77,13 +87,14 @@ private void Awake() webInstalled = true; Debug.Log("Gua Unity WebGL same-page bridge installed."); } - else + else if (remoteEnabled) { var configured = Environment.GetEnvironmentVariable("GUA_BRIDGE_PORT"); var port = int.TryParse(configured, NumberStyles.None, CultureInfo.InvariantCulture, out var value) ? value : 8765; if (!runtime.StartInspectorBridge(port)) throw new InvalidOperationException($"Failed to start Gua Inspector bridge on port {port}."); Debug.Log($"Gua Unity adapter listening on {runtime.InspectorBridgeUrl}."); } +#endif activeRuntime = this; } catch (Exception error) @@ -151,12 +162,15 @@ private void Tick() catch (Exception error) { Debug.LogError("Gua Unity world publication failed: " + error); } DispatchActions(); PumpGameInput(); +#if GUA_REMOTE_AUTOMATION if (Application.platform == RuntimePlatform.WebGLPlayer) { FlushWebActionResults(); FlushWebGameInputResults(); } +#endif ScheduleScreenshot(); } catch (Exception error) { Debug.LogError("Gua Unity adapter frame failed: " + error); } } +#if GUA_REMOTE_AUTOMATION public void HandleWebRequest(string json) { if (runtime == null) return; @@ -643,11 +657,13 @@ [Serializable] private sealed class WebCompletion } [Serializable] private sealed class WebError { public string code, message; } +#endif private void OnDestroy() { - webObserveClient?.Dispose(); webObserveClient = null; UnsubscribeGameInputSceneEvents(); if (activeRuntime == this) activeRuntime = null; +#if GUA_REMOTE_AUTOMATION + webObserveClient?.Dispose(); webObserveClient = null; if (webInstalled) { GuaUnityWebUninstall(webOwnerId); @@ -655,6 +671,7 @@ private void OnDestroy() } webCalls.Clear(); ReleaseWebGameInputSession(); +#endif foreach (var pair in uGuiClickHandlers) if (pair.Key != null) pair.Key.onClick.RemoveListener(pair.Value); foreach (var pair in visualClickHandlers) pair.Key.clicked -= pair.Value; diff --git a/cmake/GuaRemoteBuild.cmake b/cmake/GuaRemoteBuild.cmake new file mode 100644 index 00000000..2c2a412c --- /dev/null +++ b/cmake/GuaRemoteBuild.cmake @@ -0,0 +1,59 @@ +set(GUA_DISTRIBUTION_FLAVOR "shipping" CACHE STRING "Gua purpose: shipping, development, qa, ai-player") +set_property(CACHE GUA_DISTRIBUTION_FLAVOR PROPERTY STRINGS shipping development qa ai-player) +option(GUA_ENABLE_REMOTE_AUTOMATION "Compile Gua remote transports and dispatch (explicit opt-in)." OFF) +option(GUA_STAGE_IN_SAMPLE "Stage development binaries in the source sample for existing smoke workflows." OFF) +if(GUA_STAGE_IN_SAMPLE AND NOT GUA_DISTRIBUTION_FLAVOR STREQUAL "development") + message(FATAL_ERROR "Source sample staging is only permitted for development; distribute other variants from their isolated build tree") +endif() +if(GUA_STAGE_IN_SAMPLE) + set(GUA_GODOT_ADDON_DIRECTORY "${CMAKE_SOURCE_DIR}/examples/godot-gdscript/addons/gua") + set(GUA_DOTNET_ADDON_DIRECTORY "${CMAKE_SOURCE_DIR}/examples/dotnet-godot/addons/gua") +else() + set(GUA_GODOT_ADDON_DIRECTORY "${CMAKE_BINARY_DIR}/stage/addons/gua") + set(GUA_DOTNET_ADDON_DIRECTORY "${CMAKE_BINARY_DIR}/stage/dotnet-godot/addons/gua") +endif() +if(NOT GUA_DISTRIBUTION_FLAVOR MATCHES "^(shipping|development|qa|ai-player)$") + message(FATAL_ERROR "Unknown GUA_DISTRIBUTION_FLAVOR") +endif() +if(GUA_DISTRIBUTION_FLAVOR STREQUAL "shipping" AND GUA_ENABLE_REMOTE_AUTOMATION) + message(FATAL_ERROR "shipping cannot contain remote automation; select an explicit remote variant") +endif() +if(GUA_DISTRIBUTION_FLAVOR MATCHES "^(qa|ai-player)$" AND NOT GUA_ENABLE_REMOTE_AUTOMATION) + message(FATAL_ERROR "qa and ai-player require GUA_ENABLE_REMOTE_AUTOMATION=ON") +endif() +set(GUA_COMPILED_REMOTE 0) +set(GUA_COMPILED_PLAYER_ONLY 0) +set(GUA_REMOTE_CEILING "none") +if(GUA_ENABLE_REMOTE_AUTOMATION) + set(GUA_COMPILED_REMOTE 1) + set(GUA_REMOTE_CEILING "debug") + if(GUA_DISTRIBUTION_FLAVOR STREQUAL "ai-player") + set(GUA_COMPILED_PLAYER_ONLY 1) + set(GUA_REMOTE_CEILING "player") + endif() +endif() +set(GUA_RELEASE_ONLY 0) +if(GUA_DISTRIBUTION_FLAVOR MATCHES "^(shipping|qa|ai-player)$") + set(GUA_RELEASE_ONLY 1) + if(CMAKE_CONFIGURATION_TYPES) + set(CMAKE_CONFIGURATION_TYPES "Release" CACHE STRING "Allowed configurations for this variant" FORCE) + elseif(NOT CMAKE_BUILD_TYPE STREQUAL "Release") + message(FATAL_ERROR "${GUA_DISTRIBUTION_FLAVOR} requires CMAKE_BUILD_TYPE=Release") + endif() +endif() +function(gua_finalize_remote_build) + set(GUA_TRANSPORTS_JSON "[]") + if(GUA_BUILD_WS_BRIDGE AND GUA_DESKTOP_NATIVE AND NOT EMSCRIPTEN) + set(GUA_TRANSPORTS_JSON "[\\\"websocket\\\"]") + elseif(GUA_ENABLE_REMOTE_AUTOMATION AND EMSCRIPTEN) + set(GUA_TRANSPORTS_JSON "[\\\"webmcp\\\"]") + endif() + execute_process(COMMAND git rev-parse HEAD WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + OUTPUT_VARIABLE GUA_SOURCE_COMMIT OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET) + if(NOT GUA_SOURCE_COMMIT) + set(GUA_SOURCE_COMMIT "unknown") + endif() + configure_file("${CMAKE_CURRENT_FUNCTION_LIST_DIR}/build_config.h.in" "${CMAKE_BINARY_DIR}/generated/gua/build_config.h" @ONLY) +endfunction() +include_directories("${CMAKE_BINARY_DIR}/generated") +add_compile_definitions(GUA_BUILD_CONFIGURATION="$" GUA_BUILD_IS_RELEASE=$,1,0>) diff --git a/cmake/build_config.h.in b/cmake/build_config.h.in new file mode 100644 index 00000000..9170bfb0 --- /dev/null +++ b/cmake/build_config.h.in @@ -0,0 +1,14 @@ +#pragma once +#define GUA_COMPILED_REMOTE @GUA_COMPILED_REMOTE@ +#define GUA_COMPILED_PLAYER_ONLY @GUA_COMPILED_PLAYER_ONLY@ +#define GUA_DISTRIBUTION_FLAVOR "@GUA_DISTRIBUTION_FLAVOR@" +#define GUA_REMOTE_CEILING "@GUA_REMOTE_CEILING@" +#define GUA_BUILD_TRANSPORTS_JSON "@GUA_TRANSPORTS_JSON@" +#define GUA_SOURCE_COMMIT "@GUA_SOURCE_COMMIT@" +#if @GUA_RELEASE_ONLY@ && !GUA_BUILD_IS_RELEASE +#error This Gua variant requires the actual Release configuration +#endif +#define GUA_DEFAULT_OBSERVATION_PROFILE (GUA_COMPILED_PLAYER_ONLY ? GUA_OBSERVATION_PROFILE_PLAYER : GUA_OBSERVATION_PROFILE_DEBUG) +inline bool gua_build_profile_allowed(int profile) { + return profile == GUA_OBSERVATION_PROFILE_PLAYER || (!GUA_COMPILED_PLAYER_ONLY && profile == GUA_OBSERVATION_PROFILE_DEBUG); +} diff --git a/docs/remote-build-variants-evidence.md b/docs/remote-build-variants-evidence.md new file mode 100644 index 00000000..aebd411f --- /dev/null +++ b/docs/remote-build-variants-evidence.md @@ -0,0 +1,43 @@ +# 遠隔ビルド variant の検証記録 + +この文書は実装途中の証拠台帳。最終 HEAD の CI・レビュー完了とは区別する。 +設計契約は `protocol/specs/remote-build-variants.md`。 + +## ローカル環境とスナップショット + +Windows x64 / MSVC 19.51 / Visual Studio 18 2026 / CMake 4.3 / .NET 10。 +最初のローカル検証は base `decece496ea239cb811080b7aaa571e386d482e3` 上の +実装作業ツリーに対するもの。build-info の base SHA だけを、変更後コミットの証明とはしない。 +Godot は既存の公式 4.7 stable。ローカル GDExtension の依存は既存 godot-cpp source と +RefCounted/JSON/OS を含む縮小 API profile を使用した。CI は既定 API profile で構築する。 + +| 契約 | 具体的違反と検査 | ローカル結果 | +|---|---|---| +| Shipping は compile/link で遠隔なし | native shared と Godot static の実 runtime.obj に BridgeServer 参照がある、または生成 project が WS target をリンクする | `verify-remote-build-graph.ps1`、Shipping 両 target 合格 | +| Shipping は runtime で復活不可 | env/profile/API 指定後 start/running/URL が有効になる | `build_variant_tests.cpp`、Shipping 合格 | +| Development ON と OFF の分離 | ON で接続不可、または OFF が起動する | Debug の両構成で native、managed、object、package 検査合格 | +| QA 正常系 | 実 WS の公開 UI 操作や時計操作が動かない | `Gua.BuildVariants` の実 ClientWebSocket と host consume/completion 合格 | +| Player 固定と core 迂回防止 | 初期 Debug、Debug setter/client、borrowed context/unscoped read から hidden UI を取得できる | native、C#、Godot binding の各拒否と公開操作の正常系合格 | +| 入力許可失効と held cleanup | 借用 core から許可なし入力を enqueue、失効後 consume、失効した hold の中立化漏れ | native にて拒否と cleanup request の生成・完了を確認 | +| AI Player の遠隔 QA コマンド除外 | Player 版で clock capability を広告、または clock_install が成功 | 実 WS にて capability 不在・失敗を確認。QA では成功 | +| 最終 Godot consumer | 正しい class 未登録、hidden UI 漏えい、bridge 可否不一致、Shipping PCK に WebMCP resource が残る | Shipping/QA/AI Player の実 Release EXE+埋込み PCK が合格。Development は Editor/source 実行が合格 | +| 配布物の混入拒否 | QA native/Godot DLL、WebMCP script を Shipping/AI Player に混ぜる。hash も更新して検査回避を試みる | `test-remote-package-mixing.ps1` の6ケースで狙った hash/identity/余分な resource の検査が失敗することを確認 | +| 不正なビルド条件を拒否 | Shipping+ON、QA/Player+OFF、旧 WS option だけ ON、未知 variant | `test-remote-build-config.ps1` の5ケース合格 | +| Unity managed の実体除外 | Shipping assembly に HandleWebRequest が残る、または compiled constant が native 目的と違う | 既存 Unity 参照 DLL に対して実 runtime source をコンパイル。Shipping=false/false/handlerなし、Development=true/false/あり、AI Player=true/true/ありを PE metadata から確認 | + +ローカル native/managed/GDE package の5構成ログは `artifacts/remote-validation/`。 +object・生成 project の検査は各 `build/remote-*/remote-build-evidence/`、 +CTest の実出力は各 `Testing/Temporary/LastTest.log`、staging は各 `package-*`。 +Godot EXE/PCK と実行 log/result は `build/godot-variant-*`。 +混入試験の各拒否理由は `build/package-mixing-*/results.json`。 + +## 失敗・未検証を分ける + +- 初期 native/C# 試験の引数名・呼出し名の誤りは修正して再検証した。 +- 最初の Godot 縮小依存 profile に OS class が不足していたため追加して再構築した。 +- console wrapper を export template とした試行は PCK 領域がなく失敗。正規 Release template へ修正した。 +- GUI subsystem の EXE の stdout だけでは結果が得られなかったため、終了待機と専用 userdata の結果・log で実 consumer を検証した。 +- Development の editor import は終了時に失敗した。これを解決済みとは扱わず、fixture が必要とする直接の Editor/source game 実行を別に実行して合格を確認した。 +- 構成否定試験の長い一時パスが MSBuild で失敗したケースは否定試験の合格とせず、パス短縮後に狙った configure error を確認した。 +- .NET build は合格したが、この環境では NuGet vulnerability feed に接続できず NU1900 警告が残った。脆弱性 audit 成功とは主張しない。 +- Unity 実 Player/Editor のビルド・実行、各 variant の Web/WASM 実 consumer、Windows 以外の出荷 variant、最終 HEAD CI/独立・外部レビューは、この台帳初版時点では未完了。静的 IL や他 engine の合格で代用しない。 diff --git a/docs/remote-build-variants.ja.md b/docs/remote-build-variants.ja.md new file mode 100644 index 00000000..efededad --- /dev/null +++ b/docs/remote-build-variants.ja.md @@ -0,0 +1,64 @@ +# 通常出荷版・開発用・QA・AI Player のビルド + +通常出荷版は遠隔機能なし、AI Player 出荷版は Player 制限の遠隔機能あり。 +どちらも Release だが、用途と組み込む機能が違う。 + +| 用途 | CMake flavor | 明示 opt-in | 上限 | +|---|---|---|---| +| 通常出荷 | shipping | OFF | 遠隔実装なし | +| 開発 / Editor | development | OFF / ON | ローカルのみ / Debug | +| QA Release | qa | ON | Debug。Player に制限可 | +| AI Player 出荷 | ai-player | ON | コンパイル時から Player 固定 | + +ソースから通常版を作る例: + +```powershell +cmake --preset windows-shipping +cmake --build --preset windows-shipping --target gua gua-runtime gua-godot +scripts/stage-remote-build.ps1 -BuildDirectory build/windows-shipping ` + -OutputDirectory artifacts/shipping -Flavor shipping -Remote off ` + -GodotExtension build/windows-shipping/stage/addons/gua/bin/gua_godot.windows.release.x86_64.dll +``` + +QA は `windows-qa`、Player 出荷用は `windows-ai-player` を選ぶ。既存の +`windows-msvc-debug` / `windows-msvc-release` はサンプル検証用 Development +の明示 opt-in preset として維持する。後者も通常出荷版ではない。SDK の +既存テスト用 package を通常出荷へそのまま入れないこと。 + +新規 build tree の既定は shipping/OFF。通常版を実行時設定で QA に変えることは +できない。AI Player 版でも `GUA_OBSERVATION_PROFILE=debug` で権限は増えず、 +作成は失敗する。Player の公開範囲・UI 操作許可・Semantic/Raw Input の個別許可は +引き続きホストが設定する。公開したくないゲーム状態は Player policy で保護する。 + +ローカル API を使う場合は通常版でも必要な core/runtime を残せる。Gua をまったく +使わない製品なら addon ごと出荷物から除く。bridge 関数は ABI 互換 stub として +残るため、関数の有無ではなく build-info の `remoteEnabled` と権限上限を確認する。 + +Godot の `OS.has_feature` は選択補助でありコード除去ではない。staging は用途別 DLL +だけを含む descriptor を作り、通常版から WebMCP script とそのロード処理も外す。 +native shared と Godot 内蔵 runtime の実 build-info を照合する。 + +Unity のソース組込みでは `GUA_REMOTE_AUTOMATION` を明示定義した場合だけ遠隔 +bootstrap/WebMCP dispatch をコンパイルする。AI Player はさらに `GUA_PLAYER_ONLY` +を定義し、対応する native artifact と組み合わせる。プロジェクトのフラグだけで +プリビルド DLL の実装は変わらない。配布済み Gua.Unity.dll についても検証ツールが +コンパイル定数と handler の IL を読み取り、用途不一致を拒否する。 + +native NuGet pack は、全 RID の検証済み manifest を含む `GuaNativeAssetsRoot` を要求する。 +未ラベルの Windows DLL だけを拾う従来の fallback は配布の証明にならないため拒否する。 +pack の既定も Shipping/OFF。既存テスト SDK 用には +`-p:GuaDistributionFlavor=development -p:GuaEnableRemoteAutomation=true` を明示する。 +package ID は維持し、variant 別の出力先・native root を使う。同じ ID/version の +異なる variant を同じ feed に重ねて公開する運用は行わない。 + +`stage-remote-build.ps1` は空の出力先を要求し、実バイナリを probe してから +`gua-build-manifest.json` を作る。`verify-remote-build.ps1` は再度 native identity と +全ファイルの hash・余分なファイルを検査する。manifest を省略したり、名前だけを +変えても適合する成果物にはならない。各 variant のビルド/成果物を共用しない。 + +検証は `test-remote-build-variant.ps1` で native/API/実 WebSocket、MSVC の実 object と +生成 compile/link project、配布物を確認する。listener がないことや文字列検索だけで +コード不存在とは判断しない。Windows 以外・Unity 実 Player・Web の実行証拠は +各経路の CI/実機結果と区別し、別環境の成功で代用しない。 + +規範仕様は [remote-build-variants](../protocol/specs/remote-build-variants.md) を参照。 diff --git a/examples/godot-gdscript/addons/gua/gua_auto_adapter.gd b/examples/godot-gdscript/addons/gua/gua_auto_adapter.gd index 1be34ff4..e479d5af 100644 --- a/examples/godot-gdscript/addons/gua/gua_auto_adapter.gd +++ b/examples/godot-gdscript/addons/gua/gua_auto_adapter.gd @@ -148,7 +148,7 @@ func attach(root_control: Control) -> void: root = root_control last_clock_ticks_ms = Time.get_ticks_msec() last_game_input_ticks_ms = last_clock_ticks_ms - if _ensure_context() and OS.has_feature("web"): + if _ensure_context() and OS.has_feature("web") and get_build_info().get("remoteEnabled", false): var bridge_script := load("res://addons/gua/gua_webmcp_bridge.gd") if bridge_script != null: webmcp_bridge = bridge_script.new() @@ -490,6 +490,13 @@ func get_ui_tree_json() -> String: return context.get_ui_tree_json() +func get_build_info() -> Dictionary: + if not _ensure_context() or not context.has_method("get_build_info_json"): + return {} + var info: Variant = JSON.parse_string(context.get_build_info_json()) + return info if info is Dictionary else {} + + func get_version_json() -> String: if not _ensure_context(): return "{}" diff --git a/examples/unity-smoke/Assets/csc.rsp b/examples/unity-smoke/Assets/csc.rsp new file mode 100644 index 00000000..96f562fb --- /dev/null +++ b/examples/unity-smoke/Assets/csc.rsp @@ -0,0 +1 @@ +-define:GUA_REMOTE_AUTOMATION diff --git a/examples/unity-smoke/Assets/csc.rsp.meta b/examples/unity-smoke/Assets/csc.rsp.meta new file mode 100644 index 00000000..4877eab3 --- /dev/null +++ b/examples/unity-smoke/Assets/csc.rsp.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 57f07730a43b484db5c6cb09c092144e diff --git a/native/gua-core/include/gua/gua.h b/native/gua-core/include/gua/gua.h index dee62f39..b188b155 100644 --- a/native/gua-core/include/gua/gua.h +++ b/native/gua-core/include/gua/gua.h @@ -8,6 +8,13 @@ extern "C" { typedef struct gua_context_t gua_context_t; +/* Immutable build identity. This is availability, not a runtime authorization grant. */ +int gua_copy_build_info_json(char* out_json, int out_json_size); +/* Host-owned Player screenshot opt-in; remote clients cannot call configuration APIs. */ +int gua_set_player_screenshot_enabled(gua_context_t* ctx, int enabled); +/* Bits semantic/keyboard/pointer/gamepad/text, matching runtime capability bits. */ +void gua_set_player_game_input_capabilities(gua_context_t* ctx, uint32_t capabilities); + typedef struct gua_bounds_t { float x; float y; diff --git a/native/gua-core/src/gua.cpp b/native/gua-core/src/gua.cpp index af58e834..be9e69d3 100644 --- a/native/gua-core/src/gua.cpp +++ b/native/gua-core/src/gua.cpp @@ -1,4 +1,5 @@ #include "gua/gua.h" +#include "gua/build_config.h" #include "gua/semantic_lint.h" #include "input_value_schema.hpp" @@ -129,7 +130,7 @@ struct Event { unsigned long long session_epoch = 0; unsigned long long frame_sequence = 0; unsigned long long revision = 0; - int observation_profile = GUA_OBSERVATION_PROFILE_DEBUG; + int observation_profile = GUA_DEFAULT_OBSERVATION_PROFILE; unsigned long long player_revision = 0; bool player_observable = false; std::string player_value; @@ -148,7 +149,7 @@ struct ActionRequest { unsigned int modifiers; bool sensitive; int scroll_unit; - int observation_profile = GUA_OBSERVATION_PROFILE_DEBUG; + int observation_profile = GUA_DEFAULT_OBSERVATION_PROFILE; AgentPolicy agent_policy; std::string role; unsigned long long trace_epoch = 0; @@ -226,7 +227,7 @@ struct GameInputRequest { bool lease_expired = false; bool suppress_result = false; double remaining_lease_ms = 0.0; - int observation_profile = GUA_OBSERVATION_PROFILE_DEBUG; + int observation_profile = GUA_DEFAULT_OBSERVATION_PROFILE; unsigned long long trace_epoch = 0; bool guarded = false; unsigned long long action_revision = 0; @@ -1168,6 +1169,8 @@ struct gua_context_t { std::deque events; std::vector logs; Screenshot screenshot; + bool player_screenshot_enabled = false; + uint32_t player_game_input_capabilities = 0; unsigned long long next_log_sequence = 1; std::string json_cache; std::string logs_json_cache; @@ -1175,7 +1178,7 @@ struct gua_context_t { std::string diagnostics_json_cache; unsigned long long frame_sequence = 0; unsigned long long revision = 0; - int observation_profile = GUA_OBSERVATION_PROFILE_DEBUG; + int observation_profile = GUA_DEFAULT_OBSERVATION_PROFILE; unsigned long long player_revision = 0; unsigned long long next_request_id = 1; unsigned long long session_epoch = 1; @@ -1600,7 +1603,7 @@ bool one_of(int value, std::initializer_list allowed) int validate_semantic_game_input(const std::vector& actions, int operation, std::string_view target, std::string_view value, bool confirmed, - int observation_profile = GUA_OBSERVATION_PROFILE_DEBUG) + int observation_profile = GUA_DEFAULT_OBSERVATION_PROFILE) { if (operation == GUA_GAME_INPUT_RELEASE) return valid_game_input_id(target) ? GUA_GAME_INPUT_OK : GUA_GAME_INPUT_ERROR_INVALID_VALUE; @@ -1642,7 +1645,7 @@ int validate_semantic_game_input(const std::vector& actions, in } std::string build_game_input_semantic_snapshot(const std::string& context, const std::vector& actions, - int observation_profile = GUA_OBSERVATION_PROFILE_DEBUG, bool metadata = false) + int observation_profile = GUA_DEFAULT_OBSERVATION_PROFILE, bool metadata = false) { std::string json = "{\"context\":\"" + escape_json(context) + "\",\"actions\":["; bool first_action = true; @@ -1917,6 +1920,9 @@ std::string build_logs_json(const gua_context_t& ctx) std::string build_screenshot_json(const gua_context_t& ctx) { +#if GUA_COMPILED_PLAYER_ONLY + if (!ctx.player_screenshot_enabled) return "{\"dataUri\":\"\",\"width\":0,\"height\":0}"; +#endif std::string json = "{\"dataUri\":\""; json += escape_json(ctx.screenshot.data_uri); json += "\",\"width\":"; @@ -1936,7 +1942,7 @@ void append_history(gua_context_t& ctx, std::deque& history, std:: unsigned long long request_id, int action, const std::string& node_id, int status, int error_code, const std::string& value, bool sensitive, float delta_x = 0, float delta_y = 0, int bool_value = 0, const std::string& key = {}, unsigned int modifiers = 0, int scroll_unit = 0, - int observation_profile = GUA_OBSERVATION_PROFILE_DEBUG, const AgentPolicy* policy = nullptr, + int observation_profile = GUA_DEFAULT_OBSERVATION_PROFILE, const AgentPolicy* policy = nullptr, std::string_view role = {}) { if (ctx.diagnostics_history_limit == 0) return; @@ -2259,23 +2265,28 @@ extern "C" const char* gua_get_ui_tree_json(gua_context_t* ctx) } const std::lock_guard lock(ctx->mutex); - ctx->json_cache = build_ui_tree_json(*ctx); + ctx->json_cache = (GUA_COMPILED_PLAYER_ONLY ? build_ui_tree_json(*ctx, GUA_OBSERVATION_PROFILE_PLAYER) : build_ui_tree_json(*ctx)); return ctx->json_cache.c_str(); } extern "C" int gua_copy_ui_tree_json(gua_context_t* ctx, char* out_json, int out_json_size) { +#if GUA_COMPILED_PLAYER_ONLY + return gua_copy_ui_tree_json_for_profile(ctx, GUA_OBSERVATION_PROFILE_PLAYER, out_json, out_json_size); +#else + if (ctx == nullptr) { return copy_json_string("{}", out_json, out_json_size); } const std::lock_guard lock(ctx->mutex); return copy_json_string(build_ui_tree_json(*ctx), out_json, out_json_size); +#endif } extern "C" int gua_copy_ui_tree_json_for_profile(gua_context_t* ctx, int observation_profile, char* out_json, int out_json_size) { - if (ctx == nullptr || (observation_profile != GUA_OBSERVATION_PROFILE_DEBUG && observation_profile != GUA_OBSERVATION_PROFILE_PLAYER)) return 0; + if (ctx == nullptr || (!gua_build_profile_allowed(observation_profile))) return 0; const std::lock_guard lock(ctx->mutex); return copy_json_string(build_ui_tree_json(*ctx, observation_profile), out_json, out_json_size); } @@ -2287,7 +2298,7 @@ extern "C" int gua_semantic_lint_analyze(gua_context_t* ctx, const gua_semantic_ *out_report = nullptr; if (!ctx || !options || options->struct_size < sizeof(*options) || (options->include_world != 0 && options->include_world != 1) || - (options->observation_profile != GUA_OBSERVATION_PROFILE_DEBUG && options->observation_profile != GUA_OBSERVATION_PROFILE_PLAYER)) return 1; + (!gua_build_profile_allowed(options->observation_profile))) return 1; try { const std::lock_guard lock(ctx->mutex); const auto profile = options->observation_profile; @@ -2315,6 +2326,10 @@ extern "C" void gua_add_log(gua_context_t* ctx, int level, const char* message) extern "C" const char* gua_get_logs_json(gua_context_t* ctx) { +#if GUA_COMPILED_PLAYER_ONLY + return "[]"; +#else + if (ctx == nullptr) { return "[]"; } @@ -2322,16 +2337,30 @@ extern "C" const char* gua_get_logs_json(gua_context_t* ctx) const std::lock_guard lock(ctx->mutex); ctx->logs_json_cache = build_logs_json(*ctx); return ctx->logs_json_cache.c_str(); +#endif } extern "C" int gua_copy_logs_json(gua_context_t* ctx, char* out_json, int out_json_size) { +#if GUA_COMPILED_PLAYER_ONLY + return copy_json_string("[]",out_json,out_json_size); +#else + if (ctx == nullptr) { return copy_json_string("[]", out_json, out_json_size); } const std::lock_guard lock(ctx->mutex); return copy_json_string(build_logs_json(*ctx), out_json, out_json_size); +#endif +} + +extern "C" int gua_set_player_screenshot_enabled(gua_context_t* ctx,int enabled) +{ + if(!ctx) return 0; + const std::lock_guard lock(ctx->mutex); + ctx->player_screenshot_enabled=enabled!=0; + return 1; } extern "C" void gua_set_screenshot(gua_context_t* ctx, const char* data_uri, int width, int height) @@ -2396,25 +2425,40 @@ extern "C" const char* gua_get_diagnostics_json(gua_context_t* ctx) { if (ctx == nullptr) return "{}"; const std::lock_guard lock(ctx->mutex); - ctx->diagnostics_json_cache = build_diagnostics_json(*ctx); + ctx->diagnostics_json_cache = (GUA_COMPILED_PLAYER_ONLY ? build_player_diagnostics_json(*ctx) : build_diagnostics_json(*ctx)); return ctx->diagnostics_json_cache.c_str(); } extern "C" int gua_copy_diagnostics_json(gua_context_t* ctx, char* out_json, int out_json_size) { +#if GUA_COMPILED_PLAYER_ONLY + return gua_copy_diagnostics_json_for_profile(ctx, GUA_OBSERVATION_PROFILE_PLAYER, out_json, out_json_size); +#else + if (ctx == nullptr) return copy_json_string("{}", out_json, out_json_size); const std::lock_guard lock(ctx->mutex); return copy_json_string(build_diagnostics_json(*ctx), out_json, out_json_size); +#endif } extern "C" int gua_copy_diagnostics_json_for_profile(gua_context_t* ctx, int observation_profile, char* out_json, int out_json_size) { - if (ctx == nullptr || (observation_profile != GUA_OBSERVATION_PROFILE_DEBUG && observation_profile != GUA_OBSERVATION_PROFILE_PLAYER)) return 0; + if (ctx == nullptr || (!gua_build_profile_allowed(observation_profile))) return 0; const std::lock_guard lock(ctx->mutex); return copy_json_string(observation_profile == GUA_OBSERVATION_PROFILE_PLAYER ? build_player_diagnostics_json(*ctx) : build_diagnostics_json(*ctx), out_json, out_json_size); } +extern "C" int gua_copy_build_info_json(char* out_json, int out_json_size) +{ + const std::string json = "{\"schemaVersion\":1,\"flavor\":\"" GUA_DISTRIBUTION_FLAVOR + "\",\"configuration\":\"" GUA_BUILD_CONFIGURATION "\",\"remoteEnabled\":" + + std::string(GUA_COMPILED_REMOTE ? "true" : "false") + + ",\"remotePermissionCeiling\":\"" GUA_REMOTE_CEILING "\",\"buildId\":\"" GUA_BUILD_ID + "\",\"sourceCommit\":\"" GUA_SOURCE_COMMIT "\",\"transports\":" GUA_BUILD_TRANSPORTS_JSON "}"; + return copy_json_string(json, out_json, out_json_size); +} + extern "C" int gua_copy_version_json(char* out_json, int out_json_size) { return copy_json_string(build_version_json(), out_json, out_json_size); @@ -2586,6 +2630,13 @@ extern "C" int gua_clock_consume_step(gua_context_t* ctx, gua_clock_step_t* out_ extern "C" int gua_get_node_state(gua_context_t* ctx, const char* node_id, gua_node_state_t* out_state) { +#if GUA_COMPILED_PLAYER_ONLY + if (!ctx || !node_id || !out_state) return 0; + gua_node_state_v2_t projected{sizeof(projected)}; + if (!gua_get_node_state_v2_for_profile(ctx,node_id,GUA_OBSERVATION_PROFILE_PLAYER,&projected)) return 0; + out_state->visible=projected.visible; out_state->enabled=projected.enabled; return 1; +#else + if (ctx == nullptr || node_id == nullptr || out_state == nullptr) { return 0; } @@ -2601,10 +2652,15 @@ extern "C" int gua_get_node_state(gua_context_t* ctx, const char* node_id, gua_n out_state->visible = found->visible ? 1 : 0; out_state->enabled = found->enabled ? 1 : 0; return 1; +#endif } extern "C" int gua_get_node_state_v2(gua_context_t* ctx, const char* node_id, gua_node_state_v2_t* out_state) { +#if GUA_COMPILED_PLAYER_ONLY + return gua_get_node_state_v2_for_profile(ctx,node_id,GUA_OBSERVATION_PROFILE_PLAYER,out_state); +#else + if (ctx == nullptr || node_id == nullptr || out_state == nullptr || out_state->struct_size < sizeof(gua_node_state_v2_t)) { return 0; } @@ -2616,12 +2672,13 @@ extern "C" int gua_get_node_state_v2(gua_context_t* ctx, const char* node_id, gu } return copy_node_state_v2(*found, out_state) ? 1 : 0; +#endif } extern "C" int gua_get_node_state_v2_for_profile(gua_context_t* ctx, const char* node_id, int observation_profile, gua_node_state_v2_t* out_state) { if (ctx == nullptr || node_id == nullptr || out_state == nullptr || out_state->struct_size < sizeof(gua_node_state_v2_t) || - (observation_profile != GUA_OBSERVATION_PROFILE_DEBUG && observation_profile != GUA_OBSERVATION_PROFILE_PLAYER)) return 0; + (!gua_build_profile_allowed(observation_profile))) return 0; const std::lock_guard lock(ctx->mutex); const auto nodes = project_nodes(ctx->nodes, observation_profile); const auto found = std::find_if(nodes.begin(), nodes.end(), [&](const Node& node) { return node.id == node_id; }); @@ -2634,8 +2691,9 @@ extern "C" int gua_get_node_state_v3(gua_context_t* ctx, const char* node_id, gu out_state->base.struct_size = sizeof(gua_node_state_v2_t); if (gua_get_node_state_v2(ctx, node_id, &out_state->base) == 0) return 0; const std::lock_guard lock(ctx->mutex); - const auto found = std::find_if(ctx->nodes.begin(), ctx->nodes.end(), [&](const Node& node) { return node.id == node_id; }); - if (found == ctx->nodes.end()) return 0; + const auto nodes = project_nodes(ctx->nodes, GUA_DEFAULT_OBSERVATION_PROFILE); + const auto found = std::find_if(nodes.begin(), nodes.end(), [&](const Node& node) { return node.id == node_id; }); + if (found == nodes.end()) return 0; out_state->caret_position = found->caret_position; out_state->selection_start = found->selection_start; out_state->selection_end = found->selection_end; out_state->scroll_x = found->scroll_x; out_state->scroll_y = found->scroll_y; out_state->scroll_max_x = found->scroll_max_x; out_state->scroll_max_y = found->scroll_max_y; out_state->range_value = found->range_value; out_state->range_min = found->range_min; out_state->range_max = found->range_max; out_state->selected_index = found->selected_index; @@ -2644,6 +2702,10 @@ extern "C" int gua_get_node_state_v3(gua_context_t* ctx, const char* node_id, gu extern "C" int gua_find_node_by_id(gua_context_t* ctx, const char* node_id, char* out_node_id, int out_node_id_size) { +#if GUA_COMPILED_PLAYER_ONLY + return gua_find_node_by_id_for_profile(ctx,node_id,GUA_OBSERVATION_PROFILE_PLAYER,out_node_id,out_node_id_size); +#else + if (ctx == nullptr || node_id == nullptr) { return 0; } @@ -2657,10 +2719,15 @@ extern "C" int gua_find_node_by_id(gua_context_t* ctx, const char* node_id, char } return write_node_id(found->id, out_node_id, out_node_id_size); +#endif } extern "C" int gua_find_node_by_role(gua_context_t* ctx, const char* role, const char* name, char* out_node_id, int out_node_id_size) { +#if GUA_COMPILED_PLAYER_ONLY + return gua_find_node_by_role_for_profile(ctx,role,name,GUA_OBSERVATION_PROFILE_PLAYER,out_node_id,out_node_id_size); +#else + if (ctx == nullptr || role == nullptr) { return 0; } @@ -2678,10 +2745,15 @@ extern "C" int gua_find_node_by_role(gua_context_t* ctx, const char* role, const } return write_node_id(found->id, out_node_id, out_node_id_size); +#endif } extern "C" int gua_find_node_by_text(gua_context_t* ctx, const char* text, char* out_node_id, int out_node_id_size) { +#if GUA_COMPILED_PLAYER_ONLY + return gua_find_node_by_text_for_profile(ctx,text,GUA_OBSERVATION_PROFILE_PLAYER,out_node_id,out_node_id_size); +#else + if (ctx == nullptr || text == nullptr) { return 0; } @@ -2696,11 +2768,12 @@ extern "C" int gua_find_node_by_text(gua_context_t* ctx, const char* text, char* } return write_node_id(found->id, out_node_id, out_node_id_size); +#endif } extern "C" int gua_find_node_by_id_for_profile(gua_context_t* ctx, const char* node_id, int observation_profile, char* out_node_id, int out_node_id_size) { - if (ctx == nullptr || node_id == nullptr || (observation_profile != GUA_OBSERVATION_PROFILE_DEBUG && observation_profile != GUA_OBSERVATION_PROFILE_PLAYER)) return 0; + if (ctx == nullptr || node_id == nullptr || (!gua_build_profile_allowed(observation_profile))) return 0; const std::lock_guard lock(ctx->mutex); const auto nodes = project_nodes(ctx->nodes, observation_profile); const auto found = std::find_if(nodes.begin(), nodes.end(), [&](const Node& node) { return node.id == node_id; }); @@ -2709,7 +2782,7 @@ extern "C" int gua_find_node_by_id_for_profile(gua_context_t* ctx, const char* n extern "C" int gua_find_node_by_role_for_profile(gua_context_t* ctx, const char* role, const char* name, int observation_profile, char* out_node_id, int out_node_id_size) { - if (ctx == nullptr || role == nullptr || (observation_profile != GUA_OBSERVATION_PROFILE_DEBUG && observation_profile != GUA_OBSERVATION_PROFILE_PLAYER)) return 0; + if (ctx == nullptr || role == nullptr || (!gua_build_profile_allowed(observation_profile))) return 0; const std::lock_guard lock(ctx->mutex); const auto nodes = project_nodes(ctx->nodes, observation_profile); const auto found = std::find_if(nodes.begin(), nodes.end(), [&](const Node& node) { @@ -2720,7 +2793,7 @@ extern "C" int gua_find_node_by_role_for_profile(gua_context_t* ctx, const char* extern "C" int gua_find_node_by_text_for_profile(gua_context_t* ctx, const char* text, int observation_profile, char* out_node_id, int out_node_id_size) { - if (ctx == nullptr || text == nullptr || (observation_profile != GUA_OBSERVATION_PROFILE_DEBUG && observation_profile != GUA_OBSERVATION_PROFILE_PLAYER)) return 0; + if (ctx == nullptr || text == nullptr || (!gua_build_profile_allowed(observation_profile))) return 0; const std::lock_guard lock(ctx->mutex); const auto nodes = project_nodes(ctx->nodes, observation_profile); const auto found = std::find_if(nodes.begin(), nodes.end(), [&](const Node& node) { @@ -2731,17 +2804,22 @@ extern "C" int gua_find_node_by_text_for_profile(gua_context_t* ctx, const char* extern "C" int gua_query_nodes_json(gua_context_t* ctx, const gua_selector_v1_t* selector, char* out_json, int out_json_size) { +#if GUA_COMPILED_PLAYER_ONLY + return gua_query_nodes_json_for_profile(ctx,selector,GUA_OBSERVATION_PROFILE_PLAYER,out_json,out_json_size); +#else + if (ctx == nullptr || selector == nullptr || selector->struct_size < sizeof(gua_selector_v1_t)) { return copy_json_string("{\"valid\":false,\"error\":\"invalid selector struct\",\"matches\":[]}", out_json, out_json_size); } const std::lock_guard lock(ctx->mutex); return copy_json_string(build_query_json(ctx->nodes, *selector), out_json, out_json_size); +#endif } extern "C" int gua_query_nodes_json_for_profile(gua_context_t* ctx, const gua_selector_v1_t* selector, int observation_profile, char* out_json, int out_json_size) { if (ctx == nullptr || selector == nullptr || selector->struct_size < sizeof(gua_selector_v1_t) || - (observation_profile != GUA_OBSERVATION_PROFILE_DEBUG && observation_profile != GUA_OBSERVATION_PROFILE_PLAYER)) + (!gua_build_profile_allowed(observation_profile))) return copy_json_string("{\"valid\":false,\"error\":\"invalid selector struct\",\"matches\":[]}", out_json, out_json_size); const std::lock_guard lock(ctx->mutex); return copy_json_string(build_query_json(project_nodes(ctx->nodes, observation_profile), *selector), out_json, out_json_size); @@ -2923,7 +3001,7 @@ extern "C" int gua_abort_world_frame(gua_context_t* ctx) extern "C" int gua_copy_world_object_tree_json(gua_context_t* ctx, int observation_profile, char* out_json, int out_json_size) { - if (ctx == nullptr || (observation_profile != GUA_OBSERVATION_PROFILE_DEBUG && observation_profile != GUA_OBSERVATION_PROFILE_PLAYER)) return 0; + if (ctx == nullptr || (!gua_build_profile_allowed(observation_profile))) return 0; const std::lock_guard lock(ctx->mutex); return copy_json_string(build_world_tree_json(ctx->world_scene, ctx->world_objects, ctx->session_epoch, ctx->world_frame_sequence, observation_profile == GUA_OBSERVATION_PROFILE_PLAYER ? ctx->player_world_revision : ctx->world_revision, @@ -2933,7 +3011,7 @@ extern "C" int gua_copy_world_object_tree_json(gua_context_t* ctx, int observati extern "C" int gua_query_world_objects_json(gua_context_t* ctx, const gua_world_selector_v1_t* selector, int observation_profile, char* out_json, int out_json_size) { if (ctx == nullptr || selector == nullptr || selector->struct_size < sizeof(gua_world_selector_v1_t) || - (observation_profile != GUA_OBSERVATION_PROFILE_DEBUG && observation_profile != GUA_OBSERVATION_PROFILE_PLAYER) || + (!gua_build_profile_allowed(observation_profile)) || (selector->id != nullptr && selector->id[0] == '\0') || (selector->kind != nullptr && selector->kind[0] == '\0') || (selector->label != nullptr && selector->label[0] == '\0') || @@ -2956,7 +3034,7 @@ extern "C" int gua_query_world_objects_v2_json(gua_context_t* ctx, const gua_wor { if (ctx == nullptr || selector == nullptr || selector->struct_size < sizeof(gua_world_selector_v2_t) || selector->base.struct_size < sizeof(gua_world_selector_v1_t) || - (observation_profile != GUA_OBSERVATION_PROFILE_DEBUG && observation_profile != GUA_OBSERVATION_PROFILE_PLAYER) || + (!gua_build_profile_allowed(observation_profile)) || (selector->base.id != nullptr && selector->base.id[0] == '\0') || (selector->base.kind != nullptr && selector->base.kind[0] == '\0') || (selector->base.label != nullptr && selector->base.label[0] == '\0') || @@ -3023,6 +3101,17 @@ extern "C" int gua_emit_click(gua_context_t* ctx, const char* node_id) extern "C" int gua_poll_event(gua_context_t* ctx, gua_event_t* out_event) { +#if GUA_COMPILED_PLAYER_ONLY + if (!out_event) return 0; + gua_event_v2_t event{sizeof(event)}; + while (gua_poll_event_v2_for_profile(ctx,GUA_OBSERVATION_PROFILE_PLAYER,&event)) { + if (event.status == GUA_ACTION_STATUS_SUCCEEDED && (event.action==GUA_ACTION_CLICK || event.action==GUA_ACTION_FOCUS)) { + out_event->type=event.action; std::snprintf(out_event->node_id,sizeof(out_event->node_id),"%s",event.node_id); return 1; + } + } + return 0; +#else + if (ctx == nullptr || out_event == nullptr) { return 0; } @@ -3047,6 +3136,7 @@ extern "C" int gua_poll_event(gua_context_t* ctx, gua_event_t* out_event) std::snprintf(out_event->node_id, sizeof(out_event->node_id), "%s", event.node_id.c_str()); return 1; } +#endif } static int enqueue_action(gua_context_t* ctx, const gua_action_request_descriptor_t* descriptor, @@ -3054,7 +3144,7 @@ static int enqueue_action(gua_context_t* ctx, const gua_action_request_descripto { if (ctx == nullptr || descriptor == nullptr || descriptor->struct_size < sizeof(gua_action_request_descriptor_t) || descriptor->action < GUA_ACTION_CLICK || descriptor->action > GUA_ACTION_PRESS_KEY || - (observation_profile != GUA_OBSERVATION_PROFILE_DEBUG && observation_profile != GUA_OBSERVATION_PROFILE_PLAYER)) { + (!gua_build_profile_allowed(observation_profile))) { return GUA_ACTION_ERROR_INVALID_ARGUMENT; } @@ -3117,7 +3207,7 @@ static int enqueue_action(gua_context_t* ctx, const gua_action_request_descripto extern "C" int gua_enqueue_action(gua_context_t* ctx, const gua_action_request_descriptor_t* descriptor, uint64_t* out_request_id) { - return gua_enqueue_action_for_profile(ctx, descriptor, GUA_OBSERVATION_PROFILE_DEBUG, out_request_id); + return gua_enqueue_action_for_profile(ctx, descriptor, GUA_DEFAULT_OBSERVATION_PROFILE, out_request_id); } extern "C" int gua_enqueue_action_for_profile(gua_context_t* ctx, const gua_action_request_descriptor_t* descriptor, @@ -3177,6 +3267,11 @@ extern "C" int gua_get_action_request_observation_profile(gua_context_t* ctx, ui extern "C" int gua_consume_action_request_for_profile(gua_context_t* ctx, int action, const char* node_id, int current_profile, gua_action_request_t* out_request) { +#if GUA_COMPILED_PLAYER_ONLY + if (current_profile == GUA_OBSERVATION_PROFILE_DEBUG) return 0; + current_profile=GUA_OBSERVATION_PROFILE_PLAYER; +#endif + if (ctx == nullptr || out_request == nullptr || out_request->struct_size < sizeof(gua_action_request_t)) return 0; const std::string target = node_id != nullptr ? node_id : ""; const std::lock_guard lock(ctx->mutex); @@ -3300,7 +3395,7 @@ extern "C" int gua_emit_action_result(gua_context_t* ctx, const gua_action_resul return 1; } -static void copy_event_v2(const Event& event, gua_event_v2_t* out_event, int observation_profile = GUA_OBSERVATION_PROFILE_DEBUG) +static void copy_event_v2(const Event& event, gua_event_v2_t* out_event, int observation_profile = GUA_DEFAULT_OBSERVATION_PROFILE) { out_event->request_id = event.request_id; out_event->action = event.action; out_event->status = event.status; out_event->error_code = event.error_code; std::snprintf(out_event->node_id, sizeof(out_event->node_id), "%s", event.node_id.c_str()); @@ -3312,6 +3407,10 @@ static void copy_event_v2(const Event& event, gua_event_v2_t* out_event, int obs extern "C" int gua_poll_event_v2(gua_context_t* ctx, gua_event_v2_t* out_event) { +#if GUA_COMPILED_PLAYER_ONLY + return gua_poll_event_v2_for_profile(ctx,GUA_OBSERVATION_PROFILE_PLAYER,out_event); +#else + if (ctx == nullptr || out_event == nullptr || out_event->struct_size < sizeof(gua_event_v2_t)) return 0; const std::lock_guard lock(ctx->mutex); const auto found = std::find_if(ctx->events.begin(), ctx->events.end(), @@ -3327,12 +3426,13 @@ extern "C" int gua_poll_event_v2(gua_context_t* ctx, gua_event_v2_t* out_event) std::snprintf(out_event->value, sizeof(out_event->value), "%s", event.value.c_str()); out_event->sensitive = event.sensitive ? 1 : 0; return 1; +#endif } extern "C" int gua_poll_event_v2_for_profile(gua_context_t* ctx, int observation_profile, gua_event_v2_t* out_event) { if (ctx == nullptr || - (observation_profile != GUA_OBSERVATION_PROFILE_DEBUG && observation_profile != GUA_OBSERVATION_PROFILE_PLAYER) || + (!gua_build_profile_allowed(observation_profile)) || out_event == nullptr || out_event->struct_size < sizeof(gua_event_v2_t)) return 0; const std::lock_guard lock(ctx->mutex); const auto found = std::find_if(ctx->events.begin(), ctx->events.end(), [&](const Event& event) { @@ -3344,7 +3444,7 @@ extern "C" int gua_poll_event_v2_for_profile(gua_context_t* ctx, int observation extern "C" int gua_poll_event_v2_for_request_and_profile(gua_context_t* ctx, uint64_t request_id, int observation_profile, gua_event_v2_t* out_event) { if (ctx == nullptr || request_id == 0 || - (observation_profile != GUA_OBSERVATION_PROFILE_DEBUG && observation_profile != GUA_OBSERVATION_PROFILE_PLAYER) || + (!gua_build_profile_allowed(observation_profile)) || out_event == nullptr || out_event->struct_size < sizeof(gua_event_v2_t)) return 0; const std::lock_guard lock(ctx->mutex); const auto found = std::find_if(ctx->events.begin(), ctx->events.end(), [&](const Event& event) { @@ -3355,6 +3455,10 @@ extern "C" int gua_poll_event_v2_for_request_and_profile(gua_context_t* ctx, uin extern "C" int gua_poll_event_v2_for_request(gua_context_t* ctx, uint64_t request_id, gua_event_v2_t* out_event) { +#if GUA_COMPILED_PLAYER_ONLY + return gua_poll_event_v2_for_request_and_profile(ctx,request_id,GUA_OBSERVATION_PROFILE_PLAYER,out_event); +#else + if (ctx == nullptr || request_id == 0 || out_event == nullptr || out_event->struct_size < sizeof(gua_event_v2_t)) return 0; const std::lock_guard lock(ctx->mutex); const auto found = std::find_if(ctx->events.begin(), ctx->events.end(), [&](const Event& event) { @@ -3370,10 +3474,15 @@ extern "C" int gua_poll_event_v2_for_request(gua_context_t* ctx, uint64_t reques std::snprintf(out_event->value, sizeof(out_event->value), "%s", event.value.c_str()); out_event->sensitive = event.sensitive ? 1 : 0; return 1; +#endif } extern "C" int gua_poll_event_v3(gua_context_t* ctx, gua_event_v3_t* out_event) { +#if GUA_COMPILED_PLAYER_ONLY + return gua_poll_event_v3_for_profile(ctx,GUA_OBSERVATION_PROFILE_PLAYER,out_event); +#else + if (ctx == nullptr || out_event == nullptr || out_event->struct_size < sizeof(gua_event_v3_t)) return 0; const std::lock_guard lock(ctx->mutex); const auto found = std::find_if(ctx->events.begin(), ctx->events.end(), @@ -3386,10 +3495,15 @@ extern "C" int gua_poll_event_v3(gua_context_t* ctx, gua_event_v3_t* out_event) std::snprintf(out_event->base.value, sizeof(out_event->base.value), "%s", event.value.c_str()); out_event->base.sensitive = event.sensitive ? 1 : 0; out_event->session_epoch = event.session_epoch; out_event->frame_sequence = event.frame_sequence; out_event->revision = event.revision; return 1; +#endif } extern "C" int gua_poll_event_v3_for_request(gua_context_t* ctx, uint64_t request_id, gua_event_v3_t* out_event) { +#if GUA_COMPILED_PLAYER_ONLY + return gua_poll_event_v3_for_request_and_profile(ctx,request_id,GUA_OBSERVATION_PROFILE_PLAYER,out_event); +#else + if (ctx == nullptr || request_id == 0 || out_event == nullptr || out_event->struct_size < sizeof(gua_event_v3_t)) return 0; const std::lock_guard lock(ctx->mutex); const auto found = std::find_if(ctx->events.begin(), ctx->events.end(), [&](const Event& event) { @@ -3402,12 +3516,13 @@ extern "C" int gua_poll_event_v3_for_request(gua_context_t* ctx, uint64_t reques std::snprintf(out_event->base.value, sizeof(out_event->base.value), "%s", event.value.c_str()); out_event->base.sensitive = event.sensitive ? 1 : 0; out_event->session_epoch = event.session_epoch; out_event->frame_sequence = event.frame_sequence; out_event->revision = event.revision; return 1; +#endif } extern "C" int gua_poll_event_v3_for_profile(gua_context_t* ctx, int observation_profile, gua_event_v3_t* out_event) { if (ctx == nullptr || - (observation_profile != GUA_OBSERVATION_PROFILE_DEBUG && observation_profile != GUA_OBSERVATION_PROFILE_PLAYER) || + (!gua_build_profile_allowed(observation_profile)) || out_event == nullptr || out_event->struct_size < sizeof(gua_event_v3_t)) return 0; const std::lock_guard lock(ctx->mutex); const auto found = std::find_if(ctx->events.begin(), ctx->events.end(), [&](const Event& event) { @@ -3422,7 +3537,7 @@ extern "C" int gua_poll_event_v3_for_profile(gua_context_t* ctx, int observation extern "C" int gua_poll_event_v3_for_request_and_profile(gua_context_t* ctx, uint64_t request_id, int observation_profile, gua_event_v3_t* out_event) { if (ctx == nullptr || request_id == 0 || - (observation_profile != GUA_OBSERVATION_PROFILE_DEBUG && observation_profile != GUA_OBSERVATION_PROFILE_PLAYER) || + (!gua_build_profile_allowed(observation_profile)) || out_event == nullptr || out_event->struct_size < sizeof(gua_event_v3_t)) return 0; const std::lock_guard lock(ctx->mutex); const auto found = std::find_if(ctx->events.begin(), ctx->events.end(), [&](const Event& event) { @@ -3464,6 +3579,18 @@ extern "C" int gua_get_context_status(gua_context_t* ctx, gua_context_status_t* out_status->world_revision = ctx->world_revision; out_status->world_object_count = static_cast(ctx->world_objects.size()); } +#if GUA_COMPILED_PLAYER_ONLY + out_status->revision=ctx->player_revision; + out_status->node_count=static_cast(project_nodes(ctx->nodes,GUA_OBSERVATION_PROFILE_PLAYER).size()); + out_status->pending_request_count=static_cast(std::count_if(ctx->action_requests.begin(),ctx->action_requests.end(),[](const auto& r){return r.observation_profile==GUA_OBSERVATION_PROFILE_PLAYER;})); + out_status->in_flight_request_count=static_cast(std::count_if(ctx->consumed_requests.begin(),ctx->consumed_requests.end(),[](const auto& r){return r.observation_profile==GUA_OBSERVATION_PROFILE_PLAYER;})); + out_status->unconsumed_event_count=static_cast(std::count_if(ctx->events.begin(),ctx->events.end(),[](const auto& e){return e.observation_profile==GUA_OBSERVATION_PROFILE_PLAYER;})); + out_status->log_count=0; out_status->has_screenshot=ctx->player_screenshot_enabled && !ctx->screenshot.data_uri.empty(); + out_status->first_pending_action=0; out_status->first_pending_node_id[0]='\0'; + out_status->first_event_action=0; out_status->first_event_node_id[0]='\0'; + if(output_size>=sizeof(gua_context_status_t)) {out_status->world_revision=ctx->player_world_revision; + out_status->world_object_count=static_cast(project_world_objects(ctx->world_objects,GUA_OBSERVATION_PROFILE_PLAYER).size());} +#endif return 1; } @@ -3522,9 +3649,23 @@ extern "C" int gua_reset_context(gua_context_t* ctx, const gua_reset_options_t* out_report->in_flight_request_count = static_cast(ctx->consumed_requests.size() + ctx->consumed_game_input_requests.size()); out_report->unconsumed_event_count = static_cast(ctx->events.size()); fill_reset_summary(*ctx, *out_report); +#if GUA_COMPILED_PLAYER_ONLY + const auto visible_nodes=static_cast(project_nodes(ctx->nodes,GUA_OBSERVATION_PROFILE_PLAYER).size()); + const auto visible_world=static_cast(project_world_objects(ctx->world_objects,GUA_OBSERVATION_PROFILE_PLAYER).size()); + const auto project_report=[&] { + if(out_report->discarded_node_count) out_report->discarded_node_count=visible_nodes; + out_report->discarded_log_count=0; out_report->discarded_screenshot=0; + out_report->first_pending_action=0; out_report->first_pending_node_id[0]='\0'; + out_report->first_event_action=0; out_report->first_event_node_id[0]='\0'; + if(output_size>=sizeof(gua_reset_report_t) && out_report->discarded_world_object_count) out_report->discarded_world_object_count=visible_world; + }; +#endif if (options->expected_session_epoch != 0 && options->expected_session_epoch != ctx->session_epoch) { out_report->result = GUA_RESET_ERROR_STALE_EPOCH; + #if GUA_COMPILED_PLAYER_ONLY + project_report(); +#endif return out_report->result; } const bool dirty_requests = (reset_flags & GUA_RESET_REQUESTS) != 0U && @@ -3533,6 +3674,9 @@ extern "C" int gua_reset_context(gua_context_t* ctx, const gua_reset_options_t* const bool dirty_events = (reset_flags & GUA_RESET_EVENTS) != 0U && !ctx->events.empty(); if (options->strict != 0 && (dirty_requests || dirty_events)) { out_report->result = GUA_RESET_ERROR_DIRTY; + #if GUA_COMPILED_PLAYER_ONLY + project_report(); +#endif return out_report->result; } @@ -3633,7 +3777,10 @@ extern "C" int gua_reset_context(gua_context_t* ctx, const gua_reset_options_t* }); out_report->session_epoch = ctx->session_epoch; out_report->result = GUA_RESET_SUCCEEDED; - return out_report->result; + #if GUA_COMPILED_PLAYER_ONLY + project_report(); +#endif + return out_report->result; } extern "C" int gua_begin_game_input_frame(gua_context_t* ctx, const char* input_context) @@ -3787,20 +3934,20 @@ extern "C" int gua_abort_game_input_frame(gua_context_t* ctx) extern "C" int gua_copy_game_input_actions_json_v2(gua_context_t* ctx, int observation_profile, char* out_json, int out_json_size) { - if (!ctx || !one_of(observation_profile, { GUA_OBSERVATION_PROFILE_DEBUG, GUA_OBSERVATION_PROFILE_PLAYER })) return 0; + if (!ctx || !gua_build_profile_allowed(observation_profile)) return 0; const std::lock_guard lock(ctx->mutex); return copy_json_string(build_game_input_actions_json(*ctx, observation_profile, true), out_json, out_json_size); } extern "C" int gua_copy_game_input_actions_json(gua_context_t* ctx, char* out_json, int out_json_size) { - return gua_copy_game_input_actions_json_for_profile(ctx, GUA_OBSERVATION_PROFILE_DEBUG, out_json, out_json_size); + return gua_copy_game_input_actions_json_for_profile(ctx, GUA_DEFAULT_OBSERVATION_PROFILE, out_json, out_json_size); } extern "C" int gua_copy_game_input_actions_json_for_profile(gua_context_t* ctx, int observation_profile, char* out_json, int out_json_size) { - if (ctx == nullptr || !one_of(observation_profile, { GUA_OBSERVATION_PROFILE_DEBUG, GUA_OBSERVATION_PROFILE_PLAYER })) return 0; + if (ctx == nullptr || !gua_build_profile_allowed(observation_profile)) return 0; const std::lock_guard lock(ctx->mutex); return copy_json_string(build_game_input_actions_json(*ctx, observation_profile), out_json, out_json_size); } @@ -3809,7 +3956,7 @@ static int query_game_input_actions_json(gua_context_t* ctx, const gua_game_input_action_selector_v1_t* selector, int observation_profile, char* out_json, int out_json_size, bool metadata) { if (ctx == nullptr || selector == nullptr || selector->struct_size < sizeof(*selector) || - !one_of(observation_profile, { GUA_OBSERVATION_PROFILE_DEBUG, GUA_OBSERVATION_PROFILE_PLAYER }) || + !gua_build_profile_allowed(observation_profile) || selector->value_type < 0 || selector->value_type > GUA_GAME_INPUT_TEXT || selector->active < GUA_FILTER_ANY || selector->active > GUA_FILTER_TRUE || selector->limit > 100 || selector->tag_count > 16 || (selector->tag_count != 0 && selector->tags == nullptr)) return 0; @@ -3892,7 +4039,38 @@ extern "C" int gua_enqueue_game_input(gua_context_t* ctx, extern "C" int gua_enqueue_game_input_v2(gua_context_t* ctx, const gua_game_input_request_descriptor_v2_t* descriptor, uint64_t* out_request_id) { - return gua_enqueue_game_input_for_profile_v2(ctx, descriptor, GUA_OBSERVATION_PROFILE_DEBUG, out_request_id); + return gua_enqueue_game_input_for_profile_v2(ctx, descriptor, GUA_DEFAULT_OBSERVATION_PROFILE, out_request_id); +} + +extern "C" void gua_set_player_game_input_capabilities(gua_context_t* ctx,uint32_t capabilities) +{ + if(!ctx) return; + const std::lock_guard lock(ctx->mutex); + const auto next=capabilities & 31U; +#if GUA_COMPILED_PLAYER_ONLY + const auto revoked=ctx->player_game_input_capabilities & ~next; + if(revoked) { + std::unordered_set owners; + const auto revoked_kind=[&](int kind) { return kind>=1 && kind<=5 && (revoked & (1U << (kind-1)))!=0; }; + for(const auto& held:ctx->held_game_inputs) if(revoked_kind(held.kind)) owners.insert(held.owner_id); + for(const auto& request:ctx->consumed_game_input_requests) if(revoked_kind(request.kind)) { + owners.insert(request.owner_id); + ctx->released_game_input_cleanup_pending.insert(request.owner_id); + } + for(const auto owner:owners) enqueue_trace_cleanup(*ctx, GameInputRequest { + ctx->next_game_input_request_id++,owner,GUA_GAME_INPUT_CLEANUP,GUA_GAME_INPUT_RELEASE_ALL, + "all","null",0,0,0,0,false},"capability-revoked"); + std::erase_if(ctx->held_game_inputs,[&](const auto& held){return owners.contains(held.owner_id);}); + } +#endif + ctx->player_game_input_capabilities=next; +} + +static bool compiled_player_input_allowed(const gua_context_t& ctx,int kind) +{ + return !GUA_COMPILED_PLAYER_ONLY || kind==GUA_GAME_INPUT_CLEANUP || + (kind>=GUA_GAME_INPUT_SEMANTIC && kind<=GUA_GAME_INPUT_TEXT_INPUT && + (ctx.player_game_input_capabilities & (1U << (kind-1)))!=0); } static int enqueue_game_input(gua_context_t* ctx, @@ -3900,13 +4078,14 @@ static int enqueue_game_input(gua_context_t* ctx, bool guarded, uint64_t expected_session_epoch, uint64_t expected_action_revision, bool validate_only = false) { if (ctx == nullptr || descriptor == nullptr || descriptor->struct_size < sizeof(*descriptor) || - !one_of(observation_profile, { GUA_OBSERVATION_PROFILE_DEBUG, GUA_OBSERVATION_PROFILE_PLAYER }) || + !gua_build_profile_allowed(observation_profile) || (!validate_only && descriptor->owner_id == 0) || descriptor->kind < GUA_GAME_INPUT_SEMANTIC || descriptor->kind > GUA_GAME_INPUT_CLEANUP || descriptor->operation < GUA_GAME_INPUT_PRESS || descriptor->operation > GUA_GAME_INPUT_RELEASE_ALL || descriptor->lease_ms > 60000 || !std::isfinite(descriptor->x) || !std::isfinite(descriptor->y) || (descriptor->kind != GUA_GAME_INPUT_GAMEPAD && descriptor->device_index != 0)) return GUA_GAME_INPUT_ERROR_INVALID_ARGUMENT; const std::lock_guard lock(ctx->mutex); + if (!compiled_player_input_allowed(*ctx,descriptor->kind)) return GUA_GAME_INPUT_ERROR_UNSUPPORTED; if (!validate_only && !ctx->game_input_owners.contains(descriptor->owner_id)) return GUA_GAME_INPUT_ERROR_INVALID_ARGUMENT; const auto revision = observation_profile == GUA_OBSERVATION_PROFILE_PLAYER ? ctx->player_game_input_revision : ctx->game_input_revision; if (guarded && (expected_session_epoch != ctx->session_epoch || @@ -4000,7 +4179,8 @@ extern "C" int gua_consume_game_input_request(gua_context_t* ctx, gua_game_input const auto revision = request.observation_profile == GUA_OBSERVATION_PROFILE_PLAYER ? ctx->player_game_input_revision : ctx->game_input_revision; const bool stale = request.guarded && (request.trace_epoch != ctx->session_epoch || (request.kind == GUA_GAME_INPUT_SEMANTIC && request.action_revision != revision)); - const int validation = stale ? GUA_GAME_INPUT_ERROR_INVALID_ARGUMENT : request.kind == GUA_GAME_INPUT_SEMANTIC ? + const int validation = !compiled_player_input_allowed(*ctx,request.kind) ? GUA_GAME_INPUT_ERROR_UNSUPPORTED : + stale ? GUA_GAME_INPUT_ERROR_INVALID_ARGUMENT : request.kind == GUA_GAME_INPUT_SEMANTIC ? validate_semantic_game_input(ctx->game_input_actions, request.operation, request.target, request.value_json, request.confirmed, request.observation_profile) : GUA_GAME_INPUT_OK; if (validation == GUA_GAME_INPUT_OK) break; diff --git a/native/gua-core/src/observe.inc b/native/gua-core/src/observe.inc index 3b29a644..0ff95954 100644 --- a/native/gua-core/src/observe.inc +++ b/native/gua-core/src/observe.inc @@ -231,11 +231,11 @@ extern "C" int gua_observe_set_limits(gua_context_t* c,uint32_t count,uint64_t b for(auto& j:c->observe.journals) observe_trim(c->observe,j); return 0; } OBS_CATCH } extern "C" int gua_observe_snapshot(gua_context_t* c,int p,gua_observe_result_t** out) { - if(out) *out=nullptr; if(!c || !out || p<0 || p>1) return GUA_OBSERVE_ARGUMENT; + if(out) *out=nullptr; if(!c || !out || !gua_build_profile_allowed(p)) return GUA_OBSERVE_ARGUMENT; OBS_TRY { std::lock_guard lock(c->mutex); *out=new gua_observe_result_t{observe_snapshot_json(*c,p),observe_snapshot_catalogs(*c,p)}; return 0; } OBS_CATCH } extern "C" int gua_observe_subscribe(gua_context_t* c,int p,uint64_t* id,gua_observe_result_t** out) { - if(out) *out=nullptr; if(id) *id=0; if(!c || !out || !id || p<0 || p>1) return GUA_OBSERVE_ARGUMENT; + if(out) *out=nullptr; if(id) *id=0; if(!c || !out || !id || !gua_build_profile_allowed(p)) return GUA_OBSERVE_ARGUMENT; OBS_TRY { std::lock_guard lock(c->mutex); auto& s=c->observe; auto& j=s.journals[p]; auto result=std::make_unique(gua_observe_result_t{observe_snapshot_json(*c,p),observe_snapshot_catalogs(*c,p)}); auto key=s.next_id++; s.subscriptions.emplace(key,ObserveSubscription{p,c->session_epoch,j.sequence,j.generation}); diff --git a/native/gua-core/src/spatial.cpp b/native/gua-core/src/spatial.cpp index e0273af3..12ac891a 100644 --- a/native/gua-core/src/spatial.cpp +++ b/native/gua-core/src/spatial.cpp @@ -1,3 +1,5 @@ +#include "gua/gua.h" +#include "gua/build_config.h" #include "gua/spatial_host.h" #include "value_json.hpp" #include diff --git a/native/gua-core/src/spatial_host.inc b/native/gua-core/src/spatial_host.inc index adee699c..6d6763bf 100644 --- a/native/gua-core/src/spatial_host.inc +++ b/native/gua-core/src/spatial_host.inc @@ -212,7 +212,7 @@ void finish(gua_spatial_host_t& h,host_batch& b,const char* reason) { } json projected_advertisement(gua_spatial_host_t& h,uint64_t p,const json& g) { auto pi=h.providers.find(p); require(pi!=h.providers.end(),GUA_SPATIAL_NOT_AUTHORIZED); - require(boolean(g.at("enabled"))&&(text(g,"profile")=="Debug"||text(g,"profile")=="Testing"),GUA_SPATIAL_NOT_AUTHORIZED); + require(!GUA_COMPILED_PLAYER_ONLY && boolean(g.at("enabled"))&&(text(g,"profile")=="Debug"||text(g,"profile")=="Testing"),GUA_SPATIAL_NOT_AUTHORIZED); auto descriptor=pi->second.registration.at("provider"); auto& policies=descriptor.fields.at("policies").items; policies.erase(std::remove_if(policies.begin(),policies.end(),[&](const json& id) { return !contains(g.at("policies"),text(id)); }),policies.end()); require(!policies.empty(),GUA_SPATIAL_NOT_AUTHORIZED); diff --git a/native/gua-godot/CMakeLists.txt b/native/gua-godot/CMakeLists.txt index 1eb65ecf..6261d785 100644 --- a/native/gua-godot/CMakeLists.txt +++ b/native/gua-godot/CMakeLists.txt @@ -1,9 +1,17 @@ include(FetchContent) set(GODOTCPP_API_VERSION "4.7" CACHE STRING "Godot API version for the Gua GDExtension." FORCE) -set(GUA_GODOT_CONFIGURATION "Debug" CACHE STRING "Build configuration for the Gua GDExtension (Debug or Release).") +if(GUA_RELEASE_ONLY) + set(GUA_GODOT_CONFIGURATION_DEFAULT "Release") +else() + set(GUA_GODOT_CONFIGURATION_DEFAULT "Debug") +endif() +set(GUA_GODOT_CONFIGURATION "${GUA_GODOT_CONFIGURATION_DEFAULT}" CACHE STRING "Build configuration for the Gua GDExtension (Debug or Release).") set_property(CACHE GUA_GODOT_CONFIGURATION PROPERTY STRINGS Debug Release) string(TOLOWER "${GUA_GODOT_CONFIGURATION}" GUA_GODOT_CONFIGURATION_LOWER) +if(GUA_RELEASE_ONLY AND NOT GUA_GODOT_CONFIGURATION_LOWER STREQUAL "release") + message(FATAL_ERROR "This variant requires the Godot Release target") +endif() if(GUA_GODOT_CONFIGURATION_LOWER STREQUAL "debug") set(GODOTCPP_TARGET "template_debug" CACHE STRING "Godot build target for the Gua GDExtension." FORCE) elseif(GUA_GODOT_CONFIGURATION_LOWER STREQUAL "release") @@ -88,16 +96,16 @@ set_target_properties(gua-godot PROPERTIES OUTPUT_NAME "${GUA_GODOT_OUTPUT_NAME}" PREFIX "" - RUNTIME_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/examples/godot-gdscript/addons/gua/bin" - RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_SOURCE_DIR}/examples/godot-gdscript/addons/gua/bin" - RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_SOURCE_DIR}/examples/godot-gdscript/addons/gua/bin" - RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_SOURCE_DIR}/examples/godot-gdscript/addons/gua/bin" - RUNTIME_OUTPUT_DIRECTORY_MINSIZEREL "${CMAKE_SOURCE_DIR}/examples/godot-gdscript/addons/gua/bin" - LIBRARY_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/examples/godot-gdscript/addons/gua/bin" - LIBRARY_OUTPUT_DIRECTORY_DEBUG "${CMAKE_SOURCE_DIR}/examples/godot-gdscript/addons/gua/bin" - LIBRARY_OUTPUT_DIRECTORY_RELEASE "${CMAKE_SOURCE_DIR}/examples/godot-gdscript/addons/gua/bin" - LIBRARY_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_SOURCE_DIR}/examples/godot-gdscript/addons/gua/bin" - LIBRARY_OUTPUT_DIRECTORY_MINSIZEREL "${CMAKE_SOURCE_DIR}/examples/godot-gdscript/addons/gua/bin" + RUNTIME_OUTPUT_DIRECTORY "${GUA_GODOT_ADDON_DIRECTORY}/bin" + RUNTIME_OUTPUT_DIRECTORY_DEBUG "${GUA_GODOT_ADDON_DIRECTORY}/bin" + RUNTIME_OUTPUT_DIRECTORY_RELEASE "${GUA_GODOT_ADDON_DIRECTORY}/bin" + RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${GUA_GODOT_ADDON_DIRECTORY}/bin" + RUNTIME_OUTPUT_DIRECTORY_MINSIZEREL "${GUA_GODOT_ADDON_DIRECTORY}/bin" + LIBRARY_OUTPUT_DIRECTORY "${GUA_GODOT_ADDON_DIRECTORY}/bin" + LIBRARY_OUTPUT_DIRECTORY_DEBUG "${GUA_GODOT_ADDON_DIRECTORY}/bin" + LIBRARY_OUTPUT_DIRECTORY_RELEASE "${GUA_GODOT_ADDON_DIRECTORY}/bin" + LIBRARY_OUTPUT_DIRECTORY_RELWITHDEBINFO "${GUA_GODOT_ADDON_DIRECTORY}/bin" + LIBRARY_OUTPUT_DIRECTORY_MINSIZEREL "${GUA_GODOT_ADDON_DIRECTORY}/bin" ) if(EMSCRIPTEN) diff --git a/native/gua-godot/include/gua/godot/gua_context.hpp b/native/gua-godot/include/gua/godot/gua_context.hpp index 316bdb94..f824adfd 100644 --- a/native/gua-godot/include/gua/godot/gua_context.hpp +++ b/native/gua-godot/include/gua/godot/gua_context.hpp @@ -54,6 +54,7 @@ class GuaContext : public RefCounted { String get_ui_tree_json() const; String get_player_ui_tree_json() const; String get_version_json() const; + String get_build_info_json() const; int get_observation_profile() const; void set_screenshot(const String& data_uri, int width, int height); String get_screenshot_json() const; diff --git a/native/gua-godot/src/gua_context.cpp b/native/gua-godot/src/gua_context.cpp index e0e849e2..dd1f2155 100644 --- a/native/gua-godot/src/gua_context.cpp +++ b/native/gua-godot/src/gua_context.cpp @@ -464,6 +464,13 @@ String GuaContext::get_player_ui_tree_json() const return copy_runtime_json(runtime_, gua_runtime_copy_player_ui_tree_json); } +String GuaContext::get_build_info_json() const +{ + return String::utf8(gua::godot_detail::copy_json_with_retry([](char* out,int size) { + return gua_runtime_copy_build_info_json(out,size); + }).c_str()); +} + String GuaContext::get_version_json() const { return copy_runtime_json(runtime_, gua_runtime_copy_version_json); @@ -1051,6 +1058,7 @@ Dictionary GuaContext::observe_command(uint64_t client,int command,uint64_t subs void GuaContext::_bind_methods() { + ClassDB::bind_method(D_METHOD("get_build_info_json"), &GuaContext::get_build_info_json); ClassDB::bind_method(D_METHOD("create_observe_owner", "source", "runtime_id"), &GuaContext::create_observe_owner); ClassDB::bind_method(D_METHOD("destroy_observe_owner", "owner"), &GuaContext::destroy_observe_owner); ClassDB::bind_method(D_METHOD("register_observe", "owner", "name", "allow_player", "sensitive"), &GuaContext::register_observe); diff --git a/native/gua-godot/src/register_types.cpp b/native/gua-godot/src/register_types.cpp index 80e66035..8ecf7514 100644 --- a/native/gua-godot/src/register_types.cpp +++ b/native/gua-godot/src/register_types.cpp @@ -28,6 +28,11 @@ void uninitialize_gua_godot_module(ModuleInitializationLevel level) extern "C" { +int GDE_EXPORT gua_godot_copy_build_info_json(char* out_json, int out_json_size) +{ + return gua_runtime_copy_build_info_json(out_json, out_json_size); +} + GDExtensionBool GDE_EXPORT gua_godot_library_init( GDExtensionInterfaceGetProcAddress get_proc_address, GDExtensionClassLibraryPtr library, diff --git a/native/gua-godot/tests/build_variant_fixture.gd b/native/gua-godot/tests/build_variant_fixture.gd new file mode 100644 index 00000000..cf7b7485 --- /dev/null +++ b/native/gua-godot/tests/build_variant_fixture.gd @@ -0,0 +1,44 @@ +extends Node + +func _ready() -> void: + var expected: Dictionary = JSON.parse_string(FileAccess.get_file_as_string("res://expected.json")) + var extension: Resource = load("res://addons/gua/gua.gdextension") + if extension == null or not ClassDB.class_exists("GuaContext"): + fail("GuaContext was not registered by the packaged extension") + return + var context: RefCounted = ClassDB.instantiate("GuaContext") + var info: Dictionary = JSON.parse_string(str(context.call("get_build_info_json"))) + if info.get("flavor") != expected.flavor or info.get("remoteEnabled") != expected.remote: + fail("Packaged Godot build identity mismatch") + return + context.call("begin_frame", "fixture") + context.call("register_node", "public", "button", "Public", Rect2(0, 0, 10, 10), true, true) + context.call("register_node", "secret", "button", "Hidden secret", Rect2(0, 0, 10, 10), false, true) + context.call("end_frame") + var tree: String = str(context.call("get_ui_tree_json")) + if not tree.contains("Public") or tree.contains("Hidden secret") == (expected.flavor == "ai-player"): + fail("Godot legacy read ignored the compiled Player ceiling") + return + if expected.flavor == "ai-player" and int(context.call("create_observe_client", 0)) != 0: + fail("Godot binding created a Debug observation client") + return + if ResourceLoader.exists("res://addons/gua/gua_webmcp_bridge.gd") != bool(expected.remote): + fail("Packaged WebMCP resource availability does not match the variant") + return + if bool(context.call("start_inspector_bridge", 19463)) != bool(expected.remote): + fail("Godot embedded runtime transport availability mismatch") + return + context.call("stop_inspector_bridge") + context = null + var report: FileAccess = FileAccess.open("user://gua-variant-result.json", FileAccess.WRITE) + if report == null: + fail("Cannot save the actual consumer result") + return + report.store_string(JSON.stringify({"passed": true, "flavor": expected.flavor, "remote": expected.remote})) + report.close() + print("GUA_VARIANT_GODOT_PASS ", expected.flavor) + get_tree().quit(0) + +func fail(message: String) -> void: + push_error(message) + get_tree().quit(1) diff --git a/native/gua-runtime/CMakeLists.txt b/native/gua-runtime/CMakeLists.txt index 06b0a890..49451d7d 100644 --- a/native/gua-runtime/CMakeLists.txt +++ b/native/gua-runtime/CMakeLists.txt @@ -101,6 +101,15 @@ else() endif() if(BUILD_TESTING) + add_executable(gua-build-variant-tests tests/build_variant_tests.cpp) + target_link_libraries(gua-build-variant-tests PRIVATE gua-runtime) + target_compile_options(gua-build-variant-tests PRIVATE $<$:/UNDEBUG> $<$>:-UNDEBUG>) + set(GUA_TEST_REMOTE off) + if(GUA_ENABLE_REMOTE_AUTOMATION) + set(GUA_TEST_REMOTE on) + endif() + add_test(NAME gua-build-variant-tests COMMAND gua-build-variant-tests ${GUA_DISTRIBUTION_FLAVOR} ${GUA_TEST_REMOTE} ${GUA_REMOTE_CEILING}) + set_tests_properties(gua-build-variant-tests PROPERTIES TIMEOUT 20 RUN_SERIAL TRUE) add_executable(gua-runtime-guarded-profile-tests tests/guarded_profile_tests.cpp) target_link_libraries(gua-runtime-guarded-profile-tests PRIVATE gua-runtime) target_compile_options(gua-runtime-guarded-profile-tests PRIVATE $<$:/UNDEBUG> $<$>:-UNDEBUG>) @@ -121,9 +130,9 @@ endif() if(WIN32) add_custom_command(TARGET gua-runtime POST_BUILD COMMAND ${CMAKE_COMMAND} -E make_directory - "${CMAKE_SOURCE_DIR}/examples/dotnet-godot/addons/gua/bin" + "${GUA_DOTNET_ADDON_DIRECTORY}/bin" COMMAND ${CMAKE_COMMAND} -E copy_if_different "$" - "${CMAKE_SOURCE_DIR}/examples/dotnet-godot/addons/gua/bin/$" + "${GUA_DOTNET_ADDON_DIRECTORY}/bin/$" ) endif() diff --git a/native/gua-runtime/include/gua/runtime.h b/native/gua-runtime/include/gua/runtime.h index e4146aec..3f5e4047 100644 --- a/native/gua-runtime/include/gua/runtime.h +++ b/native/gua-runtime/include/gua/runtime.h @@ -9,6 +9,8 @@ extern "C" { #endif typedef struct gua_runtime_t gua_runtime_t; + +int gua_runtime_copy_build_info_json(char* out_json, int out_json_size); /* Host-only borrowed context for existing Observe C/C++ bindings. Never destroy * it. Synchronize host calls/getters with runtime destruction, as for all APIs. */ gua_context_t* gua_runtime_borrow_context(gua_runtime_t* runtime); diff --git a/native/gua-runtime/src/runtime.cpp b/native/gua-runtime/src/runtime.cpp index c30511ad..b57fbbb1 100644 --- a/native/gua-runtime/src/runtime.cpp +++ b/native/gua-runtime/src/runtime.cpp @@ -1,4 +1,5 @@ #include "gua/runtime.h" +#include "gua/build_config.h" #include "gua/semantic_lint.h" #include "../../gua-core/src/value_json.hpp" #include @@ -37,7 +38,7 @@ struct gua_runtime_t { std::vector request_ids; }; struct GameInputAuthorization { - int observation_profile = GUA_OBSERVATION_PROFILE_DEBUG; + int observation_profile = GUA_DEFAULT_OBSERVATION_PROFILE; uint64_t owner_id = 0; bool consumed = false; bool guarded = false; @@ -64,7 +65,7 @@ struct gua_runtime_t { std::unordered_map game_input_request_profiles; bool world_object_tree_enabled = false; bool player_screenshot_enabled = false; - int observation_profile = GUA_OBSERVATION_PROFILE_DEBUG; + int observation_profile = GUA_DEFAULT_OBSERVATION_PROFILE; std::atomic_bool bridge_stopping = false; uint64_t next_screenshot_request_id = 1; std::deque screenshot_requests; @@ -98,6 +99,7 @@ constexpr uint32_t all_game_input_capabilities = uint32_t effective_game_input_capabilities(const gua_runtime_t* runtime, int observation_profile) { + if (!gua_build_profile_allowed(observation_profile)) return 0; if (observation_profile == GUA_OBSERVATION_PROFILE_DEBUG) return runtime->game_input_capabilities; if (observation_profile == GUA_OBSERVATION_PROFILE_PLAYER) return runtime->game_input_capabilities & runtime->player_game_input_capabilities; @@ -193,6 +195,7 @@ std::string decorate_version_json(gua_runtime_t* runtime, std::string json,std:: } filter_runtime_capabilities(json, runtime->virtual_clock_enabled, effective_game_input_capabilities(runtime, runtime->observation_profile)); + if (GUA_COMPILED_PLAYER_ONLY) remove_capability(json, "virtual_clock_v1"); if (!runtime->world_object_tree_enabled) remove_capability(json, "world_object_tree_v1"); if (!runtime->godot_plugin_version.empty()) { const std::string marker = "\"godotPluginVersion\":null"; @@ -533,6 +536,7 @@ std::string reset_report_json(gua_runtime_t* runtime, unsigned long long expecte extern "C" gua_runtime_t* gua_runtime_create(void) { + if (GUA_COMPILED_PLAYER_ONLY && observation_profile_from_environment() == "debug") return nullptr; auto runtime = std::make_unique(); runtime->context = gua_create_context(); if (runtime->context == nullptr) { @@ -544,6 +548,11 @@ extern "C" gua_runtime_t* gua_runtime_create(void) return runtime.release(); } +extern "C" int gua_runtime_copy_build_info_json(char* out_json, int out_json_size) +{ + return gua_copy_build_info_json(out_json, out_json_size); +} + extern "C" void gua_runtime_destroy(gua_runtime_t* runtime) { if (runtime == nullptr) { @@ -565,7 +574,7 @@ extern "C" gua_context_t* gua_runtime_borrow_context(gua_runtime_t* runtime) { return valid_runtime(runtime) ? runtime->context : nullptr; } extern "C" uint64_t gua_runtime_create_observe_client(gua_runtime_t* r,int profile) { - if(!valid_runtime(r) || profile<0 || profile>1) return 0; + if(!valid_runtime(r) || !gua_build_profile_allowed(profile)) return 0; try { std::lock_guard lock(r->context_mutex); if(r->observation_profile==GUA_OBSERVATION_PROFILE_PLAYER && profile==GUA_OBSERVATION_PROFILE_DEBUG) return 0; auto id=r->next_observe_client++; @@ -1081,6 +1090,7 @@ extern "C" void gua_runtime_set_game_input_capabilities(gua_runtime_t* runtime, const std::lock_guard lock(runtime->context_mutex); runtime->game_input_capabilities = capabilities & all_game_input_capabilities; runtime->player_game_input_capabilities &= runtime->game_input_capabilities; + gua_set_player_game_input_capabilities(runtime->context,runtime->player_game_input_capabilities); } extern "C" void gua_runtime_set_player_game_input_capabilities(gua_runtime_t* runtime, uint32_t capabilities) @@ -1088,6 +1098,7 @@ extern "C" void gua_runtime_set_player_game_input_capabilities(gua_runtime_t* ru if (!valid_runtime(runtime)) return; const std::lock_guard lock(runtime->context_mutex); runtime->player_game_input_capabilities = capabilities & runtime->game_input_capabilities & all_game_input_capabilities; + gua_set_player_game_input_capabilities(runtime->context,runtime->player_game_input_capabilities); } extern "C" uint32_t gua_runtime_get_game_input_capabilities(gua_runtime_t* runtime, int observation_profile) @@ -1186,7 +1197,7 @@ static int enqueue_runtime_game_input(gua_runtime_t* runtime, bool guarded, uint64_t epoch, uint64_t revision, bool validate_only = false) { if (!valid_runtime(runtime) || descriptor == nullptr || - (observation_profile != GUA_OBSERVATION_PROFILE_DEBUG && observation_profile != GUA_OBSERVATION_PROFILE_PLAYER)) + (!gua_build_profile_allowed(observation_profile))) return GUA_GAME_INPUT_ERROR_INVALID_ARGUMENT; const std::lock_guard lock(runtime->context_mutex); if (runtime->observation_profile == GUA_OBSERVATION_PROFILE_PLAYER && observation_profile != GUA_OBSERVATION_PROFILE_PLAYER) @@ -1257,7 +1268,7 @@ extern "C" int gua_runtime_tick_game_input_leases(gua_runtime_t* runtime, double extern "C" int gua_runtime_copy_game_input_actions_json_v2(gua_runtime_t* runtime, int observation_profile, char* output, int size) { - if (!valid_runtime(runtime) || (observation_profile != GUA_OBSERVATION_PROFILE_DEBUG && observation_profile != GUA_OBSERVATION_PROFILE_PLAYER)) return 0; + if (!valid_runtime(runtime) || (!gua_build_profile_allowed(observation_profile))) return 0; const std::lock_guard lock(runtime->context_mutex); if (runtime->observation_profile == GUA_OBSERVATION_PROFILE_PLAYER && observation_profile != GUA_OBSERVATION_PROFILE_PLAYER) return 0; if ((effective_game_input_capabilities(runtime, observation_profile) & GUA_RUNTIME_GAME_INPUT_SEMANTIC) == 0) return 0; @@ -1267,7 +1278,7 @@ extern "C" int gua_runtime_copy_game_input_actions_json_v2(gua_runtime_t* runtim extern "C" int gua_runtime_query_game_input_actions_json_v2(gua_runtime_t* runtime, const gua_game_input_action_selector_v1_t* selector, int observation_profile, char* output, int size) { - if (!valid_runtime(runtime) || !selector || (observation_profile != GUA_OBSERVATION_PROFILE_DEBUG && observation_profile != GUA_OBSERVATION_PROFILE_PLAYER)) return 0; + if (!valid_runtime(runtime) || !selector || (!gua_build_profile_allowed(observation_profile))) return 0; const std::lock_guard lock(runtime->context_mutex); if (runtime->observation_profile == GUA_OBSERVATION_PROFILE_PLAYER && observation_profile != GUA_OBSERVATION_PROFILE_PLAYER) return 0; if ((effective_game_input_capabilities(runtime, observation_profile) & GUA_RUNTIME_GAME_INPUT_SEMANTIC) == 0) return 0; @@ -1294,7 +1305,7 @@ extern "C" int gua_runtime_query_game_input_actions_json(gua_runtime_t* runtime, const gua_game_input_action_selector_v1_t* selector, int observation_profile, char* out_json, int out_json_size) { if (!valid_runtime(runtime) || selector == nullptr || - (observation_profile != GUA_OBSERVATION_PROFILE_DEBUG && observation_profile != GUA_OBSERVATION_PROFILE_PLAYER)) return 0; + (!gua_build_profile_allowed(observation_profile))) return 0; const std::lock_guard lock(runtime->context_mutex); if (runtime->observation_profile == GUA_OBSERVATION_PROFILE_PLAYER && observation_profile != GUA_OBSERVATION_PROFILE_PLAYER) return 0; if ((effective_game_input_capabilities(runtime, observation_profile) & GUA_RUNTIME_GAME_INPUT_SEMANTIC) == 0) return 0; @@ -1333,7 +1344,7 @@ extern "C" void gua_runtime_set_world_object_tree_enabled(gua_runtime_t* runtime extern "C" int gua_runtime_set_observation_profile(gua_runtime_t* runtime, int profile) { - if (!valid_runtime(runtime) || (profile != GUA_OBSERVATION_PROFILE_DEBUG && profile != GUA_OBSERVATION_PROFILE_PLAYER)) return 0; + if (!valid_runtime(runtime) || (!gua_build_profile_allowed(profile))) return 0; const std::lock_guard bridge_lock(runtime->bridge_mutex); const std::lock_guard context_lock(runtime->context_mutex); if ((inspector_bridge_running_unlocked(runtime) && runtime->observation_profile != profile) || @@ -1356,6 +1367,7 @@ extern "C" int gua_runtime_set_player_screenshot_enabled(gua_runtime_t* runtime, const std::lock_guard bridge_lock(runtime->bridge_mutex); const std::lock_guard context_lock(runtime->context_mutex); if (inspector_bridge_running_unlocked(runtime)) return 0; + if (!gua_set_player_screenshot_enabled(runtime->context, enabled)) return 0; runtime->player_screenshot_enabled = enabled != 0; return 1; } @@ -1655,6 +1667,7 @@ extern "C" int gua_runtime_start_inspector_bridge(gua_runtime_t* runtime, int po runtime->bridge_stopping.store(false); gua::ws::BridgeHandlers handlers { +#if !GUA_COMPILED_PLAYER_ONLY .create_spatial_client = [runtime] { return gua_runtime_create_spatial_client(runtime); }, .release_spatial_client = [runtime](unsigned long long client) { gua_runtime_release_spatial_client(runtime,client); }, .spatial_command = [runtime](unsigned long long client,int operation,std::string_view wire,unsigned long long batch_id) -> gua::ws::CommandResult { @@ -1671,6 +1684,7 @@ extern "C" int gua_runtime_start_inspector_bridge(gua_runtime_t* runtime, int po if(status) return {false,"",spatial_safe_error(status)}; return {true,raw ? spatial_document_json(raw) : "null",""}; }, +#endif .create_observe_client = [runtime] { return gua_runtime_create_observe_client(runtime,runtime->observation_profile); }, .release_observe_client = [runtime](unsigned long long client) { gua_runtime_release_observe_client(runtime,client); }, .observe_command = [runtime](unsigned long long client,int operation,unsigned long long subscription) -> gua::ws::CommandResult { @@ -1770,6 +1784,7 @@ extern "C" int gua_runtime_start_inspector_bridge(gua_runtime_t* runtime, int po const std::lock_guard lock(runtime->context_mutex); return decorate_version_json(runtime,core_version_json(),client); }, +#if !GUA_COMPILED_PLAYER_ONLY .clock_supported = [runtime] { const std::lock_guard lock(runtime->context_mutex); return runtime->virtual_clock_enabled; @@ -1859,6 +1874,7 @@ extern "C" int gua_runtime_start_inspector_bridge(gua_runtime_t* runtime, int po } return gua::ws::CommandResult { true, std::move(json), {} }; }, +#endif .query_nodes_json = [runtime](const gua::ws::QuerySelector& selector) { gua_selector_v1_t native { sizeof(gua_selector_v1_t), diff --git a/native/gua-runtime/tests/build_variant_tests.cpp b/native/gua-runtime/tests/build_variant_tests.cpp new file mode 100644 index 00000000..2efa6c01 --- /dev/null +++ b/native/gua-runtime/tests/build_variant_tests.cpp @@ -0,0 +1,96 @@ +#include "gua/runtime.h" +#include +#include +#include +#include +#include +#include + +template std::string json(Copy copy) { + const int size=copy(nullptr,0); assert(size>0); + std::vector data(static_cast(size)); + assert(copy(data.data(),size)==size); return data.data(); +} +static void environment(const char* value) { +#ifdef _WIN32 + assert(_putenv_s("GUA_OBSERVATION_PROFILE",value)==0); +#else + assert(setenv("GUA_OBSERVATION_PROFILE",value,1)==0); +#endif +} +int main(int argc,char** argv) { + assert(argc==4); + const bool remote=std::string(argv[2])=="on", player=std::string(argv[3])=="player"; + const auto info=json(gua_runtime_copy_build_info_json); + assert(info==json(gua_copy_build_info_json)); + assert(info.find("\"flavor\":\""+std::string(argv[1])+"\"")!=std::string::npos); + assert(info.find(remote?"\"remoteEnabled\":true":"\"remoteEnabled\":false")!=std::string::npos); + environment(""); + auto* r=gua_runtime_create(); assert(r); + assert(gua_runtime_get_observation_profile(r)==(player?GUA_OBSERVATION_PROFILE_PLAYER:GUA_OBSERVATION_PROFILE_DEBUG)); + auto* c=gua_runtime_borrow_context(r); assert(c); + gua_begin_frame(c,"variant"); + gua_register_node(c,"public","button","Public",{0,0,10,10},1,1); + gua_register_node(c,"secret","button","Hidden secret",{0,0,10,10},0,1); + gua_end_frame(c); + const auto tree=json([&](char* out,int size){return gua_copy_ui_tree_json(c,out,size);}); + assert((tree.find("Hidden secret")==std::string::npos)==player); + assert(tree.find("Public")!=std::string::npos); + char buffer[8192]{}; + if(player) { + assert(!gua_runtime_set_observation_profile(r,GUA_OBSERVATION_PROFILE_DEBUG)); + assert(!gua_runtime_create_observe_client(r,GUA_OBSERVATION_PROFILE_DEBUG)); + assert(!gua_runtime_create_spatial_client(r)); + assert(!gua_copy_ui_tree_json_for_profile(c,GUA_OBSERVATION_PROFILE_DEBUG,buffer,sizeof(buffer))); + gua_observe_result_t* observation=nullptr; + assert(gua_observe_snapshot(c,GUA_OBSERVATION_PROFILE_DEBUG,&observation)==GUA_OBSERVE_ARGUMENT); + assert(!observation); + gua_node_state_v2_t state{sizeof(state)}; + assert(!gua_get_node_state_v2(c,"secret",&state)); + assert(gua_enqueue_click(c,"secret")==0); + const auto owner=gua_create_game_input_owner(c); + gua_game_input_request_descriptor_v2_t input{sizeof(input),owner,GUA_GAME_INPUT_KEYBOARD,GUA_GAME_INPUT_DOWN,"KeyA","null",0,0,5000}; + uint64_t id=0; + assert(gua_enqueue_game_input_v2(c,&input,&id)==GUA_GAME_INPUT_ERROR_UNSUPPORTED); + gua_runtime_set_game_input_capabilities(r,GUA_RUNTIME_GAME_INPUT_KEYBOARD); + gua_runtime_set_player_game_input_capabilities(r,GUA_RUNTIME_GAME_INPUT_KEYBOARD); + assert(gua_enqueue_game_input_v2(c,&input,&id)==GUA_GAME_INPUT_OK); + // Revocation must be enforced even through a borrowed core context. + gua_runtime_set_player_game_input_capabilities(r,0); + gua_game_input_request_v1_t consumed{sizeof(consumed)}; + assert(!gua_consume_game_input_request(c,&consumed)); + gua_runtime_set_player_game_input_capabilities(r,GUA_RUNTIME_GAME_INPUT_KEYBOARD); + assert(gua_enqueue_game_input_v2(c,&input,&id)==GUA_GAME_INPUT_OK); + assert(gua_consume_game_input_request(c,&consumed)); + assert(gua_complete_game_input_request(c,consumed.request_id,1,0)); + gua_runtime_set_player_game_input_capabilities(r,0); + assert(gua_consume_game_input_request(c,&consumed)); + assert(consumed.kind==GUA_GAME_INPUT_CLEANUP && consumed.operation==GUA_GAME_INPUT_RELEASE_ALL); + assert(gua_complete_game_input_request(c,consumed.request_id,1,0)); + assert(gua_release_game_input_owner(c,owner)); + } + assert(gua_enqueue_click(c,"public")); + assert(gua_consume_click_request(c,"public")); + assert(gua_emit_click(c,"public")); + gua_reset_options_t reset{sizeof(reset),GUA_RESET_DEFAULT_V3,0,0,GUA_RESET_FLAGS_VERSION_CURRENT}; + gua_reset_report_t report{sizeof(report)}; + assert(gua_runtime_reset_context(r,&reset,&report)==GUA_RESET_SUCCEEDED); + if(player) { assert(!gua_runtime_set_observation_profile(r,0)); assert(gua_runtime_get_observation_profile(r)==1); } + assert(!gua_runtime_start_inspector_bridge(r,0)); + const int port=19461; + assert((gua_runtime_start_inspector_bridge(r,port)!=0)==remote); + assert((gua_runtime_inspector_bridge_running(r)!=0)==remote); + assert((std::strlen(gua_runtime_inspector_bridge_url(r))!=0)==remote); + gua_runtime_stop_inspector_bridge(r); gua_runtime_stop_inspector_bridge(r); + assert(!gua_runtime_inspector_bridge_running(r)); + assert(std::strlen(gua_runtime_inspector_bridge_url(r))==0); + gua_runtime_destroy(r); + environment("debug"); + r=gua_runtime_create(); assert((r==nullptr)==player); if(r) gua_runtime_destroy(r); + environment("player"); r=gua_runtime_create(); assert(r); + assert(gua_runtime_get_observation_profile(r)==1); + assert(!gua_runtime_set_observation_profile(r,0)); + if(!remote) assert(!gua_runtime_start_inspector_bridge(r,port)); + gua_runtime_destroy(r); environment(""); + std::cout<&1) + $lines | Set-Content -LiteralPath (Join-Path $fixture 'export.log') + if ($LASTEXITCODE) { throw 'Godot Release export failed.' } + $executable = Join-Path $fixture 'output/fixture.exe' + $arguments = @('--headless') + } + $arguments += @('--log-file',"`"$(Join-Path $fixture 'consumer.log')`"") + $process = Start-Process -FilePath $executable -ArgumentList $arguments -WindowStyle Hidden -PassThru ` + -RedirectStandardOutput (Join-Path $fixture 'consumer.stdout.log') -RedirectStandardError (Join-Path $fixture 'consumer.stderr.log') + if (-not $process.WaitForExit(30000)) { $process.Kill($true); throw 'Godot consumer timed out.' } + $results = @(Get-ChildItem -LiteralPath $env:APPDATA -Recurse -Filter gua-variant-result.json -File) + if ($process.ExitCode -ne 0 -or $results.Count -ne 1) { Get-Content -LiteralPath (Join-Path $fixture 'consumer.log') -ErrorAction SilentlyContinue; throw 'Actual Godot consumer acceptance failed.' } + $result = Get-Content -LiteralPath $results[0].FullName -Raw | ConvertFrom-Json + if ($result.passed -ne $true -or $result.flavor -ne $Flavor -or $result.remote -ne ($Remote -eq 'on')) { throw 'Consumer result does not match the requested variant.' } + Write-Host "Godot $(if ($Editor) {'Editor'} else {'Release EXE/PCK'}) acceptance passed: $fixture" +} finally { + $env:APPDATA = $previousAppData + $env:GUA_OBSERVATION_PROFILE = $previousProfile +} diff --git a/scripts/test-remote-build-config.ps1 b/scripts/test-remote-build-config.ps1 new file mode 100644 index 00000000..855e2f89 --- /dev/null +++ b/scripts/test-remote-build-config.ps1 @@ -0,0 +1,21 @@ +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$cases = @( + @{Name='shipping-on';Args=@('-DGUA_DISTRIBUTION_FLAVOR=shipping','-DGUA_ENABLE_REMOTE_AUTOMATION=ON');Reason='shipping cannot contain'}, + @{Name='qa-off';Args=@('-DGUA_DISTRIBUTION_FLAVOR=qa','-DGUA_ENABLE_REMOTE_AUTOMATION=OFF');Reason='require GUA_ENABLE_REMOTE_AUTOMATION'}, + @{Name='player-off';Args=@('-DGUA_DISTRIBUTION_FLAVOR=ai-player','-DGUA_ENABLE_REMOTE_AUTOMATION=OFF');Reason='require GUA_ENABLE_REMOTE_AUTOMATION'}, + @{Name='legacy-transport-bypass';Args=@('-DGUA_DISTRIBUTION_FLAVOR=shipping','-DGUA_ENABLE_REMOTE_AUTOMATION=OFF','-DGUA_BUILD_WS_BRIDGE=ON');Reason='requires the explicit remote'}, + @{Name='unknown';Args=@('-DGUA_DISTRIBUTION_FLAVOR=unknown');Reason='Unknown GUA_DISTRIBUTION_FLAVOR'} +) +$evidence = Join-Path $root ('build/cfg-' + [Guid]::NewGuid().ToString('N').Substring(0,8)) +New-Item -ItemType Directory $evidence | Out-Null +$caseNumber = 0 +foreach ($case in $cases) { + $caseNumber++ + $lines = @(cmake -S $root -B (Join-Path $evidence $caseNumber) -G 'Visual Studio 18 2026' -A x64 -DGUA_BUILD_GODOT=OFF -DGUA_BUILD_EXAMPLES=OFF @($case.Args) 2>&1) + $exitCode = $LASTEXITCODE + $lines | Set-Content -LiteralPath (Join-Path $evidence ($case.Name + '.log')) + if ($exitCode -eq 0 -or ($lines -join "`n") -notmatch $case.Reason) { throw "Configuration rejection did not detect $($case.Name)." } +} +Write-Host "All five invalid configurations rejected: $evidence" +exit 0 diff --git a/scripts/test-remote-build-variant.ps1 b/scripts/test-remote-build-variant.ps1 new file mode 100644 index 00000000..b034a3db --- /dev/null +++ b/scripts/test-remote-build-variant.ps1 @@ -0,0 +1,49 @@ +param( + [Parameter(Mandatory)][ValidateSet('shipping','development','qa','ai-player')][string]$Flavor, + [Parameter(Mandatory)][ValidateSet('on','off')][string]$Remote, + [ValidateSet('Debug','Release')][string]$Configuration = 'Release', + [switch]$WithGodot, + [string]$GodotCppSource, + [string]$GodotBuildProfile, + [string]$PythonExecutable, + [string]$BuildDirectory +) +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +if (-not $BuildDirectory) { $BuildDirectory = Join-Path $root "build/remote-$Flavor-$Remote-$Configuration" } +$build = [IO.Path]::GetFullPath($BuildDirectory) +$remoteValue = if ($Remote -eq 'on') { 'ON' } else { 'OFF' } +$configure = @('-S',$root,'-B',$build,'-G','Visual Studio 18 2026','-A','x64', + "-DGUA_DISTRIBUTION_FLAVOR=$Flavor","-DGUA_ENABLE_REMOTE_AUTOMATION=$remoteValue", + '-DGUA_STAGE_IN_SAMPLE=OFF','-DGUA_BUILD_EXAMPLES=OFF','-DBUILD_TESTING=ON', + "-DGUA_BUILD_GODOT=$(if ($WithGodot) { 'ON' } else { 'OFF' })","-DGUA_GODOT_CONFIGURATION=$Configuration") +if ($GodotCppSource) { $configure += "-DFETCHCONTENT_SOURCE_DIR_GODOT-CPP=$GodotCppSource" } +if ($GodotBuildProfile) { $configure += "-DGODOTCPP_BUILD_PROFILE=$GodotBuildProfile" } +if ($PythonExecutable) { $configure += "-DPython3_EXECUTABLE=$PythonExecutable" } +$sourceCommit = (& git -C $root rev-parse HEAD).Trim() +if ($LASTEXITCODE) { throw 'Cannot resolve source identity.' } +$configure += "-DGUA_BUILD_ID=$sourceCommit" +cmake @configure +if ($LASTEXITCODE) { throw 'Variant configure failed.' } +$targets = @('gua','gua-build-variant-tests') +if ($WithGodot) { $targets += 'gua-godot' } +cmake --build $build --config $Configuration --target @targets --parallel 1 -- /nr:false +if ($LASTEXITCODE) { throw 'Variant compilation failed.' } +$previousPath = $env:PATH +try { + $env:PATH = (Join-Path $build "native/gua-runtime/$Configuration") + [IO.Path]::PathSeparator + $env:PATH + ctest --test-dir $build -C $Configuration -R '^gua-build-variant-tests$' --output-on-failure + if ($LASTEXITCODE) { throw 'Native variant acceptance failed.' } +} finally { $env:PATH = $previousPath } +& (Join-Path $PSScriptRoot 'verify-remote-build-graph.ps1') -BuildDirectory $build -Configuration $Configuration -Remote $Remote +$project = Join-Path $root 'bindings/dotnet/tests/Gua.BuildVariants/Gua.BuildVariants.csproj' +dotnet build $project -c Release --nologo -v quiet -m:1 -nr:false +if ($LASTEXITCODE) { throw 'Managed variant test build failed.' } +dotnet (Join-Path $root 'bindings/dotnet/tests/Gua.BuildVariants/bin/Release/net10.0/Gua.BuildVariants.dll') ` + (Join-Path $build "native/gua-core/$Configuration") (Join-Path $build "native/gua-runtime/$Configuration") $Flavor $Remote +if ($LASTEXITCODE) { throw 'Managed/real WebSocket acceptance failed.' } +$stageArgs = @{ BuildDirectory = $build; OutputDirectory = (Join-Path $build ('package-' + [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssfff'))); Flavor = $Flavor; Remote = $Remote; Configuration = $Configuration; + EngineBuildKind = $(if ($Configuration -eq 'Debug') { 'debug' } else { 'release' }) } +if ($WithGodot) { $stageArgs.GodotExtension = Join-Path $build "stage/addons/gua/bin/gua_godot.windows.$($Configuration.ToLowerInvariant()).x86_64.dll" } +& (Join-Path $PSScriptRoot 'stage-remote-build.ps1') @stageArgs +Write-Host "Completed $Flavor / remote $Remote / $Configuration acceptance." diff --git a/scripts/test-remote-package-mixing.ps1 b/scripts/test-remote-package-mixing.ps1 new file mode 100644 index 00000000..4414b704 --- /dev/null +++ b/scripts/test-remote-package-mixing.ps1 @@ -0,0 +1,43 @@ +param( + [Parameter(Mandatory)][string]$ShippingPackage, + [Parameter(Mandatory)][string]$QAPackage, + [Parameter(Mandatory)][string]$AIPlayerPackage +) +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +dotnet build (Join-Path $root 'tools/Gua.ArtifactProbe/Gua.ArtifactProbe.csproj') -c Release --nologo -v quiet -m:1 -nr:false +if ($LASTEXITCODE) { throw 'Cannot build the binary probe.' } +$verify = Join-Path $PSScriptRoot 'verify-remote-build.ps1' +& $verify -Directory $ShippingPackage -Flavor shipping -Remote off +& $verify -Directory $QAPackage -Flavor qa -Remote on +& $verify -Directory $AIPlayerPackage -Flavor ai-player -Remote on +$evidence = Join-Path $root ('build/package-mixing-' + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory $evidence | Out-Null +$results = [Collections.Generic.List[object]]::new() +function Test-MixedFile([string]$Name,[string]$Base,[string]$Flavor,[string]$Remote,[string]$Relative,[bool]$RewriteHash,[string]$ExpectedReason) { + $directoryPath = Join-Path $evidence $Name + Copy-Item -LiteralPath $Base -Destination $directoryPath -Recurse + Copy-Item -LiteralPath (Join-Path $QAPackage $Relative) -Destination (Join-Path $directoryPath $Relative) -Force + if ($RewriteHash) { + $manifestPath = Join-Path $directoryPath 'gua-build-manifest.json' + $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + $entry = @($manifest.files | Where-Object path -CEQ $Relative) + if ($entry.Count -eq 1) { $entry[0].sha256 = (Get-FileHash -LiteralPath (Join-Path $directoryPath $Relative)).Hash.ToLowerInvariant() } + else { $manifest.files += @{path=$Relative;sha256=(Get-FileHash -LiteralPath (Join-Path $directoryPath $Relative)).Hash.ToLowerInvariant()} } + $manifest | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $manifestPath -Encoding utf8NoBOM + } + $reason = $null + try { & $verify -Directory $directoryPath -Flavor $Flavor -Remote $Remote *> (Join-Path $evidence "$Name.log") } + catch { $reason = $_.Exception.Message } + if (-not $reason -or $reason -notmatch $ExpectedReason) { throw "Fault $Name did not trigger the required assertion: $reason" } + $results.Add(@{case=$Name;rejected=$true;reason=$reason}) +} +Test-MixedFile 'shipping-qa-core' $ShippingPackage shipping off 'gua.dll' $false 'Packaged bytes differ' +Test-MixedFile 'shipping-rehashed-qa-core' $ShippingPackage shipping off 'gua.dll' $true 'binary identity differs' +Test-MixedFile 'player-rehashed-qa-runtime' $AIPlayerPackage ai-player on 'gua_runtime.dll' $true 'binary identity differs' +Test-MixedFile 'player-rehashed-qa-godot' $AIPlayerPackage ai-player on 'addons/gua/bin/gua_godot.windows.release.x86_64.dll' $true 'binary identity differs' +Test-MixedFile 'shipping-extra-webmcp' $ShippingPackage shipping off 'addons/gua/gua_webmcp_bridge.gd' $false 'Unmanifested package file' +Test-MixedFile 'shipping-rehashed-webmcp' $ShippingPackage shipping off 'addons/gua/gua_webmcp_bridge.gd' $true 'Remote implementation found' +if ($results.Count -ne 6) { throw 'Missing fault results.' } +$results | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $evidence 'results.json') -Encoding utf8NoBOM +Write-Host "All six deliberate package mixing faults rejected: $evidence" diff --git a/scripts/verify-native-assets-manifests.ps1 b/scripts/verify-native-assets-manifests.ps1 new file mode 100644 index 00000000..d6dedf58 --- /dev/null +++ b/scripts/verify-native-assets-manifests.ps1 @@ -0,0 +1,25 @@ +param( + [Parameter(Mandatory)][string]$NativeAssetsRoot, + [Parameter(Mandatory)][ValidateSet('shipping','development','qa','ai-player')][string]$Flavor, + [Parameter(Mandatory)][ValidateSet('true','false')][string]$EnableRemoteAutomation +) +$ErrorActionPreference = 'Stop' +$enabled = $EnableRemoteAutomation -eq 'true' +if (($Flavor -eq 'shipping' -and $enabled) -or ($Flavor -in 'qa','ai-player' -and -not $enabled)) { throw 'Invalid package purpose and remote opt-in.' } +$ceiling = if (-not $enabled) { 'none' } elseif ($Flavor -eq 'ai-player') { 'player' } else { 'debug' } +$source = $null; $buildId = $null +foreach ($rid in 'win-x64','linux-x64','osx-x64','osx-arm64') { + $directoryPath = Join-Path $NativeAssetsRoot $rid + $manifest = Get-Content -LiteralPath (Join-Path $directoryPath 'gua-build-manifest.json') -Raw | ConvertFrom-Json + $identity = $manifest.build + if ($manifest.schemaVersion -ne 1 -or $identity.flavor -ne $Flavor -or $identity.remoteEnabled -ne $enabled -or $identity.remotePermissionCeiling -ne $ceiling -or + $identity.sourceCommit -notmatch '^[0-9a-f]{40}$' -or [string]::IsNullOrWhiteSpace($identity.buildId) -or ($Flavor -ne 'development' -and $identity.configuration -ne 'Release')) { throw "Wrong native variant: $rid" } + if ($source -and ($identity.sourceCommit -ne $source -or $identity.buildId -ne $buildId)) { throw 'Native RIDs came from different builds.' } + $source = $identity.sourceCommit; $buildId = $identity.buildId + $names = if ($rid -eq 'win-x64') { @('gua.dll','gua_runtime.dll') } elseif ($rid -eq 'linux-x64') { @('libgua.so','libgua_runtime.so') } else { @('libgua.dylib','libgua_runtime.dylib') } + foreach ($name in $names) { + $entry = @($manifest.files | Where-Object path -CEQ $name) + if ($entry.Count -ne 1 -or (Get-FileHash -LiteralPath (Join-Path $directoryPath $name) -Algorithm SHA256).Hash.ToLowerInvariant() -ne $entry[0].sha256) { throw "Unverified native asset: $rid/$name" } + } +} +Write-Host "Verified all native RID manifests: $Flavor, remote $EnableRemoteAutomation" diff --git a/scripts/verify-remote-build-graph.ps1 b/scripts/verify-remote-build-graph.ps1 new file mode 100644 index 00000000..47b6e4c3 --- /dev/null +++ b/scripts/verify-remote-build-graph.ps1 @@ -0,0 +1,39 @@ +param( + [Parameter(Mandatory)][string]$BuildDirectory, + [Parameter(Mandatory)][ValidateSet('on','off')][string]$Remote, + [ValidateSet('Debug','Release')][string]$Configuration = 'Release' +) +$ErrorActionPreference = 'Stop' +$build = [IO.Path]::GetFullPath($BuildDirectory) +$header = Get-Content -LiteralPath (Join-Path $build 'generated/gua/build_config.h') -Raw +$expected = if ($Remote -eq 'on') { 1 } else { 0 } +if ($header -notmatch "(?m)^#define GUA_COMPILED_REMOTE $expected\r?$") { throw 'Wrong generated compile gate.' } +$projects = @(Get-ChildItem -LiteralPath $build -Recurse -Filter '*.vcxproj') +if (-not $projects.Count) { throw 'This object inspection currently requires the Windows MSVC generator.' } +$bridge = @($projects | Where-Object Name -EQ 'gua-ws-bridge.vcxproj') +if (($bridge.Count -eq 1) -ne ($Remote -eq 'on')) { throw 'Wrong transport target/link graph (including stale targets).' } +$runtimeProjects = @($projects | Where-Object Name -In 'gua-runtime.vcxproj','gua-runtime-godot.vcxproj') +if (-not $runtimeProjects.Count) { throw 'Missing native runtime target.' } +foreach ($project in $runtimeProjects) { + $text = Get-Content -LiteralPath $project.FullName -Raw + if ($text -notmatch "GUA_RUNTIME_WITH_WS=$expected(?:;|&|<)") { throw "Wrong runtime compile gate: $($project.Name)" } + if ($Remote -eq 'off' -and $text -match 'gua-ws-bridge') { throw 'Transport linked into Shipping runtime.' } +} +$compilerFile = Get-ChildItem -LiteralPath (Join-Path $build 'CMakeFiles') -Recurse -Filter CMakeCXXCompiler.cmake | Select-Object -First 1 +$compilerInfo = Get-Content -LiteralPath $compilerFile.FullName -Raw +if ($compilerInfo -notmatch 'set\(CMAKE_CXX_COMPILER "([^"]+)"\)') { throw 'Cannot find the actual MSVC toolchain.' } +$dumpbin = Join-Path ([IO.Path]::GetDirectoryName($Matches[1])) 'dumpbin.exe' +$evidence = Join-Path $build 'remote-build-evidence' +New-Item -ItemType Directory -Force $evidence | Out-Null +foreach ($project in $runtimeProjects) { + $targetName = [IO.Path]::GetFileNameWithoutExtension($project.Name) + $object = Join-Path $project.DirectoryName "$targetName.dir/$Configuration/runtime.obj" + if (-not (Test-Path -LiteralPath $object -PathType Leaf)) { throw "Missing compiled runtime object: $object" } + $symbols = @(& $dumpbin /symbols $object) + if ($LASTEXITCODE) { throw 'Object inspection failed.' } + $symbols | Set-Content -LiteralPath (Join-Path $evidence "$targetName-symbols.txt") + if ($Remote -eq 'off' -and ($symbols -match 'BridgeServer').Count) { throw 'Remote server reference present in disabled object.' } + Copy-Item -LiteralPath $project.FullName -Destination $evidence -Force +} +Copy-Item -LiteralPath (Join-Path $build 'generated/gua/build_config.h') -Destination $evidence -Force +Write-Host "Verified compile/link graph and actual runtime objects: remote $Remote" diff --git a/scripts/verify-remote-build.ps1 b/scripts/verify-remote-build.ps1 new file mode 100644 index 00000000..bc1ea4f3 --- /dev/null +++ b/scripts/verify-remote-build.ps1 @@ -0,0 +1,44 @@ +param( + [Parameter(Mandatory)][string]$Directory, + [Parameter(Mandatory)][ValidateSet('shipping','development','qa','ai-player')][string]$Flavor, + [Parameter(Mandatory)][ValidateSet('on','off')][string]$Remote +) +$ErrorActionPreference = 'Stop' +if (($Flavor -eq 'shipping' -and $Remote -ne 'off') -or ($Flavor -in 'qa','ai-player' -and $Remote -ne 'on')) { throw 'Invalid purpose/opt-in combination.' } +$directoryPath = [IO.Path]::GetFullPath($Directory) +$manifest = Get-Content -LiteralPath (Join-Path $directoryPath 'gua-build-manifest.json') -Raw | ConvertFrom-Json +$ceiling = if ($Remote -eq 'off') { 'none' } elseif ($Flavor -eq 'ai-player') { 'player' } else { 'debug' } +if ($manifest.schemaVersion -ne 1 -or $manifest.build.flavor -ne $Flavor -or $manifest.build.remoteEnabled -ne ($Remote -eq 'on') -or $manifest.build.remotePermissionCeiling -ne $ceiling) { throw 'Unexpected package variant.' } +if ($Flavor -ne 'development' -and ($manifest.build.configuration -ne 'Release' -or $manifest.engineBuildKind -ne 'release')) { throw 'Wrong distribution configuration.' } +if (@($manifest.files).Count -lt 3) { throw 'Incomplete package manifest.' } +$root = Split-Path -Parent $PSScriptRoot +$probe = Join-Path $root 'tools/Gua.ArtifactProbe/bin/Release/net10.0/Gua.ArtifactProbe.dll' +if (-not (Test-Path -LiteralPath $probe -PathType Leaf)) { throw 'Build Gua.ArtifactProbe before package verification.' } +$coreCount = 0; $runtimeCount = 0 +$seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) +foreach ($file in $manifest.files) { + if ([IO.Path]::IsPathRooted($file.path) -or $file.path -match '(^|[\\/])\.\.([\\/]|$)' -or -not $seen.Add($file.path)) { throw 'Invalid or duplicate manifest path.' } + $path = [IO.Path]::GetFullPath((Join-Path $directoryPath $file.path)) + if (-not $path.StartsWith($directoryPath + [IO.Path]::DirectorySeparatorChar,[StringComparison]::OrdinalIgnoreCase)) { throw 'Manifest path escaped package.' } + if (-not (Test-Path -LiteralPath $path -PathType Leaf) -or (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() -ne $file.sha256) { throw "Packaged bytes differ from the verified build: $($file.path)" } + if ((Get-Item -LiteralPath $path).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Package files must not be links.' } + $symbol = $null + if ($file.path -match '^(gua\.dll|libgua\.(so|dylib))$') { $symbol = 'gua_copy_build_info_json'; $coreCount++ } + if ($file.path -match '^(gua_runtime\.dll|libgua_runtime\.(so|dylib))$') { $symbol = 'gua_runtime_copy_build_info_json'; $runtimeCount++ } + if ($file.path -match '^addons/gua/bin/gua_godot\.') { $symbol = 'gua_godot_copy_build_info_json' } + if ($symbol) { + $identity = (& dotnet $probe native $path $symbol) | ConvertFrom-Json + if ($LASTEXITCODE -or ($identity | ConvertTo-Json -Compress -Depth 10) -ne ($manifest.build | ConvertTo-Json -Compress -Depth 10)) { throw 'Packaged binary identity differs from manifest.' } + } + if ($file.path -eq 'Gua.Unity.dll') { + $identity = (& dotnet $probe managed $path) | ConvertFrom-Json + if ($LASTEXITCODE -or $identity.RemoteEnabled -ne ($Remote -eq 'on') -or $identity.PlayerOnly -ne ($Flavor -eq 'ai-player') -or $identity.remoteCommandHandler -ne ($Remote -eq 'on')) { throw 'Packaged Unity managed IL has the wrong remote variant.' } + } + if ($Remote -eq 'off' -and $file.path -match '(?i)(gua_webmcp_bridge\.gd|GuaWebMcp\.jslib|gua[-_]ws[-_]bridge)') { throw 'Remote implementation found in a disabled package.' } +} +if ($coreCount -ne 1 -or $runtimeCount -ne 1) { throw 'Package needs exactly one core/runtime native pair.' } +foreach ($file in Get-ChildItem -LiteralPath $directoryPath -Recurse -File) { + $relative = [IO.Path]::GetRelativePath($directoryPath,$file.FullName).Replace('\','/') + if ($relative -ne 'gua-build-manifest.json' -and -not $seen.Contains($relative)) { throw "Unmanifested package file: $relative" } +} +Write-Host "Verified $Flavor / remote $Remote package: $directoryPath" diff --git a/scripts/write-native-pair-manifest.ps1 b/scripts/write-native-pair-manifest.ps1 new file mode 100644 index 00000000..2394e1c0 --- /dev/null +++ b/scripts/write-native-pair-manifest.ps1 @@ -0,0 +1,23 @@ +param( + [Parameter(Mandatory)][string]$Directory, + [Parameter(Mandatory)][ValidateSet('shipping','development','qa','ai-player')][string]$Flavor, + [Parameter(Mandatory)][ValidateSet('on','off')][string]$Remote +) +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +dotnet build (Join-Path $root 'tools/Gua.ArtifactProbe/Gua.ArtifactProbe.csproj') -c Release --nologo -v quiet -m:1 -nr:false +if ($LASTEXITCODE) { throw 'Cannot build artifact identity probe.' } +$probe = Join-Path $root 'tools/Gua.ArtifactProbe/bin/Release/net10.0/Gua.ArtifactProbe.dll' +$core = if ($IsWindows) { 'gua.dll' } elseif ($IsMacOS) { 'libgua.dylib' } else { 'libgua.so' } +$runtime = if ($IsWindows) { 'gua_runtime.dll' } elseif ($IsMacOS) { 'libgua_runtime.dylib' } else { 'libgua_runtime.so' } +$coreInfo = (& dotnet $probe native (Join-Path $Directory $core) gua_copy_build_info_json) | ConvertFrom-Json +if ($LASTEXITCODE) { throw 'Native core build identity unavailable.' } +$runtimeInfo = (& dotnet $probe native (Join-Path $Directory $runtime) gua_runtime_copy_build_info_json) | ConvertFrom-Json +if ($LASTEXITCODE) { throw 'Native runtime build identity unavailable.' } +$ceiling = if ($Remote -eq 'off') { 'none' } elseif ($Flavor -eq 'ai-player') { 'player' } else { 'debug' } +if (($Flavor -eq 'shipping' -and $Remote -ne 'off') -or ($Flavor -in 'qa','ai-player' -and $Remote -ne 'on') -or + $coreInfo.flavor -ne $Flavor -or $coreInfo.remoteEnabled -ne ($Remote -eq 'on') -or $coreInfo.remotePermissionCeiling -ne $ceiling -or + ($Flavor -ne 'development' -and $coreInfo.configuration -ne 'Release') -or $coreInfo.sourceCommit -notmatch '^[0-9a-f]{40}$' -or + ($coreInfo | ConvertTo-Json -Compress -Depth 10) -ne ($runtimeInfo | ConvertTo-Json -Compress -Depth 10)) { throw 'Native pair has a different compiled variant.' } +$files = @($core,$runtime | ForEach-Object { @{path=$_;sha256=(Get-FileHash -LiteralPath (Join-Path $Directory $_) -Algorithm SHA256).Hash.ToLowerInvariant()} }) +@{schemaVersion=1;build=$coreInfo;files=$files} | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath (Join-Path $Directory 'gua-build-manifest.json') -Encoding utf8NoBOM diff --git a/tools/Gua.ArtifactProbe/Gua.ArtifactProbe.csproj b/tools/Gua.ArtifactProbe/Gua.ArtifactProbe.csproj new file mode 100644 index 00000000..61f1c87c --- /dev/null +++ b/tools/Gua.ArtifactProbe/Gua.ArtifactProbe.csproj @@ -0,0 +1,6 @@ + + + Exenet10.0 + enableenable + + diff --git a/tools/Gua.ArtifactProbe/Program.cs b/tools/Gua.ArtifactProbe/Program.cs new file mode 100644 index 00000000..bb03c3cc --- /dev/null +++ b/tools/Gua.ArtifactProbe/Program.cs @@ -0,0 +1,43 @@ +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Runtime.InteropServices; +using System.Text.Json; + +if(args.Length<2) throw new ArgumentException("native path symbol | managed path"); +if(args[0]=="native") { + if(args.Length!=3) throw new ArgumentException("native path symbol"); + nint library=NativeLibrary.Load(Path.GetFullPath(args[1])); + try { + var copy=Marshal.GetDelegateForFunctionPointer(NativeLibrary.GetExport(library,args[2])); + int size=copy(0,0); + if(size<=0 || size>65536) throw new InvalidDataException("Invalid build identity size"); + nint data=Marshal.AllocHGlobal(size); + try { + if(copy(data,size)!=size) throw new InvalidDataException("Build identity changed"); + string text=Marshal.PtrToStringUTF8(data) ?? throw new InvalidDataException("Missing build identity"); + using var parsed=JsonDocument.Parse(text); + Console.WriteLine(parsed.RootElement.GetRawText()); + } finally { Marshal.FreeHGlobal(data); } + } finally { NativeLibrary.Free(library); } +} else if(args[0]=="managed") { + using var stream=File.OpenRead(args[1]); using var pe=new PEReader(stream); + var reader=pe.GetMetadataReader(); + var values=new Dictionary(); + foreach(var handle in reader.TypeDefinitions) { + var type=reader.GetTypeDefinition(handle); + if(reader.GetString(type.Namespace)!="Gua.Unity") continue; + if(reader.GetString(type.Name)=="GuaUnityCompiledBuild") { + foreach(var fieldHandle in type.GetFields()) { + var field=reader.GetFieldDefinition(fieldHandle); var constant=reader.GetConstant(field.GetDefaultValue()); + values[reader.GetString(field.Name)]=reader.GetBlobReader(constant.Value).ReadBoolean(); + } + } + if(reader.GetString(type.Name)=="GuaUnityRuntime") + values["remoteCommandHandler"]=type.GetMethods().Any(m=>reader.GetString(reader.GetMethodDefinition(m).Name)=="HandleWebRequest"); + } + if(!values.ContainsKey("RemoteEnabled") || !values.ContainsKey("PlayerOnly")) throw new InvalidDataException("Missing compiled managed variant"); + Console.WriteLine(JsonSerializer.Serialize(values)); +} else throw new ArgumentException("Unknown probe mode"); + +[UnmanagedFunctionPointer(CallingConvention.Cdecl)] +delegate int CopyJson(nint output,int size); diff --git a/tools/Gua.UnityVariantCompile/Gua.UnityVariantCompile.csproj b/tools/Gua.UnityVariantCompile/Gua.UnityVariantCompile.csproj new file mode 100644 index 00000000..72d08f25 --- /dev/null +++ b/tools/Gua.UnityVariantCompile/Gua.UnityVariantCompile.csproj @@ -0,0 +1,24 @@ + + + netstandard2.1latest + falseenable + Gua.Unity + Gua.Unity.Editor + $(DefineConstants);GUA_REMOTE_AUTOMATION + $(DefineConstants);GUA_PLAYER_ONLY + $(DefineConstants);UNITY_EDITOR + + + + + + + + + + + + + + + From b4c8d74c35c0f56e836e424c0bb6ed4b8f4ea6a7 Mon Sep 17 00:00:00 2001 From: link1345 Date: Sun, 1 Nov 2026 11:11:58 +0900 Subject: [PATCH 2/5] =?UTF-8?q?Player=20=E3=81=AE=E3=82=A4=E3=83=99?= =?UTF-8?q?=E3=83=B3=E3=83=88=E5=A2=83=E7=95=8C=E3=81=A8=20Unity=20?= =?UTF-8?q?=E9=85=8D=E5=B8=83=E6=A4=9C=E6=9F=BB=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/gua-release.yml | 1 + .github/workflows/remote-build-variants.yml | 74 +++++++++++++++++++ .../unity/Editor/GuaUnityEditorCommands.cs | 5 +- bindings/unity/Editor/GuaVariantBuildGuard.cs | 23 ++++-- docs/remote-build-variants-evidence.md | 16 ++++ native/gua-core/src/gua.cpp | 33 +++++---- native/gua-runtime/CMakeLists.txt | 6 ++ native/gua-runtime/tests/build_info_probe.cpp | 10 +++ .../gua-runtime/tests/build_variant_tests.cpp | 31 ++++++++ scripts/build-web-native.ps1 | 29 ++++++-- scripts/get-msvc-generator.ps1 | 10 +++ scripts/package-unity-upm-ci.ps1 | 10 ++- scripts/stage-remote-build.ps1 | 3 +- scripts/test-remote-build-config.ps1 | 3 +- scripts/test-remote-build-variant.ps1 | 3 +- scripts/verify-dotnet-native-packages.ps1 | 15 ++++ scripts/verify-remote-build.ps1 | 3 +- scripts/verify-unity-variant-package.ps1 | 38 ++++++++++ tools/Gua.ArtifactProbe/Program.cs | 2 + 19 files changed, 281 insertions(+), 34 deletions(-) create mode 100644 native/gua-runtime/tests/build_info_probe.cpp create mode 100644 scripts/get-msvc-generator.ps1 create mode 100644 scripts/verify-unity-variant-package.ps1 diff --git a/.github/workflows/gua-release.yml b/.github/workflows/gua-release.yml index 8db989a7..1fe66d92 100644 --- a/.github/workflows/gua-release.yml +++ b/.github/workflows/gua-release.yml @@ -448,6 +448,7 @@ jobs: New-Item -ItemType Directory -Force (Join-Path $root "unity"), (Join-Path $godot "bin") | Out-Null Copy-Item "build/web-release/native/gua-runtime/libgua_runtime.a" (Join-Path $root "unity") Copy-Item "build/web-release/native/gua-core/libgua-core.a" (Join-Path $root "unity") + Copy-Item "build/web-release/stage/unity-web/gua-build-manifest.json" (Join-Path $root "unity") scripts/stage-godot-addon-sources.ps1 -AddonDirectory $godot -Version '${{ needs.windows-assets.outputs.version }}' Copy-Item "examples/godot-gdscript/addons/gua/bin/gua_godot.web.debug.wasm32.wasm" (Join-Path $godot "bin") Copy-Item "examples/godot-gdscript/addons/gua/bin/gua_godot.web.release.wasm32.wasm" (Join-Path $godot "bin") diff --git a/.github/workflows/remote-build-variants.yml b/.github/workflows/remote-build-variants.yml index bffebefe..4332fa0c 100644 --- a/.github/workflows/remote-build-variants.yml +++ b/.github/workflows/remote-build-variants.yml @@ -34,6 +34,34 @@ jobs: $args = @{ Flavor = '${{ matrix.flavor }}'; Remote = '${{ matrix.remote }}'; Configuration = '${{ matrix.configuration }}' } if ('${{ matrix.godot }}' -eq 'true') { $args.WithGodot = $true } scripts/test-remote-build-variant.ps1 @args + - name: Reject invalid configuration combinations + if: matrix.name == 'shipping' + shell: pwsh + run: scripts/test-remote-build-config.ps1 + - name: Download existing Godot editor and Release template + if: matrix.godot + shell: pwsh + run: | + Invoke-WebRequest 'https://github.com/godotengine/godot-builds/releases/download/4.7-stable/Godot_v4.7-stable_win64.exe.zip' -OutFile godot.zip + Invoke-WebRequest 'https://github.com/godotengine/godot-builds/releases/download/4.7-stable/Godot_v4.7-stable_export_templates.tpz' -OutFile templates.zip + Expand-Archive godot.zip godot-editor + Expand-Archive templates.zip godot-templates + - name: Run packaged Godot consumer and preserve selected package + if: matrix.godot + shell: pwsh + run: | + $package = Get-ChildItem 'build/remote-${{ matrix.flavor }}-${{ matrix.remote }}-${{ matrix.configuration }}' -Directory -Filter 'package-*' | Sort-Object Name | Select-Object -Last 1 + $args = @{ PackageDirectory = $package.FullName; GodotExecutable = "$PWD/godot-editor/Godot_v4.7-stable_win64_console.exe"; Flavor = '${{ matrix.flavor }}'; Remote = '${{ matrix.remote }}' } + if ('${{ matrix.flavor }}' -eq 'development') { $args.Editor = $true } else { $args.ReleaseTemplate = "$PWD/godot-templates/templates/windows_release_x86_64.exe" } + scripts/test-godot-remote-build.ps1 @args + New-Item -ItemType Directory -Force artifacts/selected | Out-Null + Copy-Item -LiteralPath $package.FullName -Destination artifacts/selected/package -Recurse + - uses: actions/upload-artifact@v4 + if: matrix.godot + with: + name: selected-${{ matrix.name }}-${{ github.sha }} + path: artifacts/selected/package + if-no-files-found: error - name: Preserve build and packaged-consumer evidence if: always() uses: actions/upload-artifact@v4 @@ -43,4 +71,50 @@ jobs: build/remote-*/remote-build-evidence/** build/remote-*/Testing/Temporary/** build/remote-*/package-*/** + build/godot-variant-*/consumer.log + build/godot-variant-*/userdata/**/gua-variant-result.json + build/cfg-*/** + if-no-files-found: error + package-mixing: + needs: windows-variants + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - uses: actions/download-artifact@v4 + with: + pattern: selected-*-${{ github.sha }} + path: artifacts/mixing + - shell: pwsh + run: scripts/test-remote-package-mixing.ps1 -ShippingPackage artifacts/mixing/selected-shipping-${{ github.sha }} -QAPackage artifacts/mixing/selected-qa-${{ github.sha }} -AIPlayerPackage artifacts/mixing/selected-ai-player-${{ github.sha }} + - uses: actions/upload-artifact@v4 + if: always() + with: + name: remote-mixing-${{ github.sha }} + path: build/package-mixing-*/results.json + if-no-files-found: error + web-variants: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { flavor: shipping, remote: 'off' } + - { flavor: development, remote: 'on' } + - { flavor: qa, remote: 'on' } + - { flavor: ai-player, remote: 'on' } + steps: + - uses: actions/checkout@v4 + - uses: emscripten-core/setup-emsdk@v15 + with: + version: 4.0.20 + actions-cache-folder: emsdk-cache + - shell: pwsh + run: scripts/build-web-native.ps1 -RuntimeOnly -Flavor '${{ matrix.flavor }}' -Remote '${{ matrix.remote }}' -Configuration Release -BuildId '${{ github.sha }}' + - uses: actions/upload-artifact@v4 + with: + name: static-web-${{ matrix.flavor }}-${{ github.sha }} + path: build/web-release/stage/unity-web if-no-files-found: error diff --git a/bindings/unity/Editor/GuaUnityEditorCommands.cs b/bindings/unity/Editor/GuaUnityEditorCommands.cs index c20411ae..9959e8ad 100644 --- a/bindings/unity/Editor/GuaUnityEditorCommands.cs +++ b/bindings/unity/Editor/GuaUnityEditorCommands.cs @@ -20,7 +20,8 @@ public static void PrepareProject() public static void BuildPlayer() { // This command builds the repository's development fixture, not a shipping game. - Environment.SetEnvironmentVariable("GUA_BUILD_VARIANT", Argument("-guaVariant") ?? "development"); + var buildVariant = Argument("-guaVariant") ?? "development"; + Environment.SetEnvironmentVariable("GUA_BUILD_VARIANT", buildVariant); PrepareProject(); var scene = Argument("-guaScene") ?? throw new ArgumentException("-guaScene is required."); var output = Argument("-guaOutput") ?? throw new ArgumentException("-guaOutput is required."); @@ -50,7 +51,7 @@ public static void BuildPlayer() var report = BuildPipeline.BuildPlayer(new BuildPlayerOptions { scenes = new[] { scene }, locationPathName = output, - target = target, options = BuildOptions.Development, + target = target, options = buildVariant == "development" ? BuildOptions.Development : BuildOptions.None, }); if (report.summary.result != UnityEditor.Build.Reporting.BuildResult.Succeeded) throw new InvalidOperationException($"Gua Unity player build failed: {report.summary.result} ({report.summary.totalErrors} errors)."); diff --git a/bindings/unity/Editor/GuaVariantBuildGuard.cs b/bindings/unity/Editor/GuaVariantBuildGuard.cs index 6623acb7..c3321953 100644 --- a/bindings/unity/Editor/GuaVariantBuildGuard.cs +++ b/bindings/unity/Editor/GuaVariantBuildGuard.cs @@ -8,7 +8,6 @@ using UnityEditor; using UnityEditor.Build; using UnityEditor.Build.Reporting; -using UnityEditor.PackageManager; namespace Gua.Unity.Editor { @@ -27,23 +26,33 @@ public void OnPreprocessBuild(BuildReport report) throw new BuildFailedException("Shipping, QA and AI Player require a Release Player build."); try { - using var loaded = JsonDocument.Parse(GuaBuildInfo.GetJson()); - ValidateIdentity(loaded.RootElement, expected); + // WebGL archives cannot execute in the Editor; validate their emitted build identity and bytes. + bool web = report.summary.platform == BuildTarget.WebGL; + using var loaded = web ? null : JsonDocument.Parse(GuaBuildInfo.GetJson()); + if (!web) + { + ValidateIdentity(loaded!.RootElement, expected); using var runtime = new GuaRuntime(); if (runtime.GetBuildInfoJson() != GuaBuildInfo.GetJson()) throw new InvalidDataException("Loaded core/runtime build mismatch."); + } + string? sourceCommit = null, buildId = null; int selectedCore = 0, selectedRuntime = 0; foreach (var importer in PluginImporter.GetAllImporters()) { if (!importer.GetCompatibleWithPlatform(report.summary.platform)) continue; string name = Path.GetFileName(importer.assetPath); - if (name != "gua.dll" && name != "gua_runtime.dll" && name != "libgua.so" && name != "libgua_runtime.so" && name != "libgua.dylib" && name != "libgua_runtime.dylib") continue; + if (web ? (name != "libgua-core.a" && name != "libgua_runtime.a") : + (name != "gua.dll" && name != "gua_runtime.dll" && name != "libgua.so" && name != "libgua_runtime.so" && name != "libgua.dylib" && name != "libgua_runtime.dylib")) continue; string file = ResolveAsset(importer.assetPath); string manifestPath = Path.Combine(Path.GetDirectoryName(file)!, "gua-build-manifest.json"); using var manifest = JsonDocument.Parse(File.ReadAllText(manifestPath)); ValidateIdentity(manifest.RootElement.GetProperty("build"), expected); var fileBuild = manifest.RootElement.GetProperty("build"); - if (fileBuild.GetProperty("sourceCommit").GetString() != loaded.RootElement.GetProperty("sourceCommit").GetString() || - fileBuild.GetProperty("buildId").GetString() != loaded.RootElement.GetProperty("buildId").GetString()) + sourceCommit ??= web ? fileBuild.GetProperty("sourceCommit").GetString() : loaded!.RootElement.GetProperty("sourceCommit").GetString(); + buildId ??= web ? fileBuild.GetProperty("buildId").GetString() : loaded!.RootElement.GetProperty("buildId").GetString(); + if (string.IsNullOrEmpty(sourceCommit) || string.IsNullOrEmpty(buildId) || + fileBuild.GetProperty("sourceCommit").GetString() != sourceCommit || + fileBuild.GetProperty("buildId").GetString() != buildId) throw new InvalidDataException("Imported native assets came from different builds."); var entries = manifest.RootElement.GetProperty("files").EnumerateArray().Where(e => e.GetProperty("path").GetString() == name).ToArray(); if (entries.Length != 1) throw new InvalidDataException("Missing or duplicate native file identity."); @@ -77,7 +86,7 @@ private static string ResolveAsset(string asset) { if (asset.StartsWith("Packages/", StringComparison.Ordinal)) { - var package = PackageInfo.FindForAssetPath(asset) ?? throw new InvalidDataException("Cannot resolve Gua package."); + var package = UnityEditor.PackageManager.PackageInfo.FindForAssetPath(asset) ?? throw new InvalidDataException("Cannot resolve Gua package."); return Path.Combine(package.resolvedPath, asset.Substring(("Packages/" + package.name + "/").Length)); } return Path.GetFullPath(asset); diff --git a/docs/remote-build-variants-evidence.md b/docs/remote-build-variants-evidence.md index aebd411f..98ac0ac9 100644 --- a/docs/remote-build-variants-evidence.md +++ b/docs/remote-build-variants-evidence.md @@ -41,3 +41,19 @@ Godot EXE/PCK と実行 log/result は `build/godot-variant-*`。 - 構成否定試験の長い一時パスが MSBuild で失敗したケースは否定試験の合格とせず、パス短縮後に狙った configure error を確認した。 - .NET build は合格したが、この環境では NuGet vulnerability feed に接続できず NU1900 警告が残った。脆弱性 audit 成功とは主張しない。 - Unity 実 Player/Editor のビルド・実行、各 variant の Web/WASM 実 consumer、Windows 以外の出荷 variant、最終 HEAD CI/独立・外部レビューは、この台帳初版時点では未完了。静的 IL や他 engine の合格で代用しない。 + +## 独立監査 pass 1 と修正 + +固定 HEAD `f1d81f7c6fc6f13454b9599df3dca2722bc5e1f2` を独立・読み取り専用で監査し、4件を修正した。 +Unity の PackageInfo 曖昧参照は Editor guard 実コンパイルで再現し、限定名で解消。 +WebGL は desktop DLL の必須数判定から static archive の manifest/hash 検査へ分岐し、 +Emscripten で実リンクした Node probe の immutable build-info から archive manifest を生成する。 +Player legacy polling は click/focus だけを取り出し、set-value 完了の request-specific v3 取得を壊さない。 +reset/status のイベント数と strict dirty 判定は公開 Player イベントへ投影する。 +後二件は `build_variant_tests.cpp` に exact-count / correlation / private-public 比較を加え、 +AI Player Release の実 native テストで合格(`artifacts/remote-validation/ai-review-fixes.log`)。 +Unity runtime source の PE metadata では Shipping の remote handler / native start reference / +Web install import がすべて absent、Development/AI Player ではすべて present。 +`artifacts/remote-validation/unity-editor-guard.log` の最初の失敗は修正後の成功ログへ更新した。 + +追加した Web static probe/UPM多プラットフォーム検査と新 CI consumer job は、最終 HEAD CI の結果で判定する。 diff --git a/native/gua-core/src/gua.cpp b/native/gua-core/src/gua.cpp index be9e69d3..f45e2682 100644 --- a/native/gua-core/src/gua.cpp +++ b/native/gua-core/src/gua.cpp @@ -3101,17 +3101,6 @@ extern "C" int gua_emit_click(gua_context_t* ctx, const char* node_id) extern "C" int gua_poll_event(gua_context_t* ctx, gua_event_t* out_event) { -#if GUA_COMPILED_PLAYER_ONLY - if (!out_event) return 0; - gua_event_v2_t event{sizeof(event)}; - while (gua_poll_event_v2_for_profile(ctx,GUA_OBSERVATION_PROFILE_PLAYER,&event)) { - if (event.status == GUA_ACTION_STATUS_SUCCEEDED && (event.action==GUA_ACTION_CLICK || event.action==GUA_ACTION_FOCUS)) { - out_event->type=event.action; std::snprintf(out_event->node_id,sizeof(out_event->node_id),"%s",event.node_id); return 1; - } - } - return 0; -#else - if (ctx == nullptr || out_event == nullptr) { return 0; } @@ -3120,6 +3109,10 @@ extern "C" int gua_poll_event(gua_context_t* ctx, gua_event_t* out_event) while (true) { const auto legacy_event = std::find_if(ctx->events.begin(), ctx->events.end(), [&](const Event& event) { return !ctx->owned_action_requests.contains(event.request_id) && +#if GUA_COMPILED_PLAYER_ONLY + event_matches_profile(event, GUA_OBSERVATION_PROFILE_PLAYER) && + event_observable_for_profile(ctx->nodes, event, GUA_OBSERVATION_PROFILE_PLAYER) && +#endif (event.action == GUA_ACTION_CLICK || event.action == GUA_ACTION_FOCUS); }); if (legacy_event == ctx->events.end()) { @@ -3136,7 +3129,6 @@ extern "C" int gua_poll_event(gua_context_t* ctx, gua_event_t* out_event) std::snprintf(out_event->node_id, sizeof(out_event->node_id), "%s", event.node_id.c_str()); return 1; } -#endif } static int enqueue_action(gua_context_t* ctx, const gua_action_request_descriptor_t* descriptor, @@ -3584,7 +3576,9 @@ extern "C" int gua_get_context_status(gua_context_t* ctx, gua_context_status_t* out_status->node_count=static_cast(project_nodes(ctx->nodes,GUA_OBSERVATION_PROFILE_PLAYER).size()); out_status->pending_request_count=static_cast(std::count_if(ctx->action_requests.begin(),ctx->action_requests.end(),[](const auto& r){return r.observation_profile==GUA_OBSERVATION_PROFILE_PLAYER;})); out_status->in_flight_request_count=static_cast(std::count_if(ctx->consumed_requests.begin(),ctx->consumed_requests.end(),[](const auto& r){return r.observation_profile==GUA_OBSERVATION_PROFILE_PLAYER;})); - out_status->unconsumed_event_count=static_cast(std::count_if(ctx->events.begin(),ctx->events.end(),[](const auto& e){return e.observation_profile==GUA_OBSERVATION_PROFILE_PLAYER;})); + out_status->unconsumed_event_count=static_cast(std::count_if(ctx->events.begin(),ctx->events.end(),[&](const auto& event){ + return event_matches_profile(event,GUA_OBSERVATION_PROFILE_PLAYER) && event_observable_for_profile(ctx->nodes,event,GUA_OBSERVATION_PROFILE_PLAYER); + })); out_status->log_count=0; out_status->has_screenshot=ctx->player_screenshot_enabled && !ctx->screenshot.data_uri.empty(); out_status->first_pending_action=0; out_status->first_pending_node_id[0]='\0'; out_status->first_event_action=0; out_status->first_event_node_id[0]='\0'; @@ -3652,7 +3646,13 @@ extern "C" int gua_reset_context(gua_context_t* ctx, const gua_reset_options_t* #if GUA_COMPILED_PLAYER_ONLY const auto visible_nodes=static_cast(project_nodes(ctx->nodes,GUA_OBSERVATION_PROFILE_PLAYER).size()); const auto visible_world=static_cast(project_world_objects(ctx->world_objects,GUA_OBSERVATION_PROFILE_PLAYER).size()); + const auto visible_events=static_cast(std::count_if(ctx->events.begin(),ctx->events.end(),[&](const Event& event) { + return event_matches_profile(event,GUA_OBSERVATION_PROFILE_PLAYER) && + event_observable_for_profile(ctx->nodes,event,GUA_OBSERVATION_PROFILE_PLAYER); + })); const auto project_report=[&] { + out_report->unconsumed_event_count=visible_events; + if(out_report->discarded_event_count) out_report->discarded_event_count=visible_events; if(out_report->discarded_node_count) out_report->discarded_node_count=visible_nodes; out_report->discarded_log_count=0; out_report->discarded_screenshot=0; out_report->first_pending_action=0; out_report->first_pending_node_id[0]='\0'; @@ -3671,7 +3671,12 @@ extern "C" int gua_reset_context(gua_context_t* ctx, const gua_reset_options_t* const bool dirty_requests = (reset_flags & GUA_RESET_REQUESTS) != 0U && (!ctx->action_requests.empty() || !ctx->consumed_requests.empty() || pending_game_input_count != 0 || !ctx->consumed_game_input_requests.empty()); - const bool dirty_events = (reset_flags & GUA_RESET_EVENTS) != 0U && !ctx->events.empty(); + const bool dirty_events = (reset_flags & GUA_RESET_EVENTS) != 0U && +#if GUA_COMPILED_PLAYER_ONLY + visible_events != 0; +#else + !ctx->events.empty(); +#endif if (options->strict != 0 && (dirty_requests || dirty_events)) { out_report->result = GUA_RESET_ERROR_DIRTY; #if GUA_COMPILED_PLAYER_ONLY diff --git a/native/gua-runtime/CMakeLists.txt b/native/gua-runtime/CMakeLists.txt index 49451d7d..0dc0c8a8 100644 --- a/native/gua-runtime/CMakeLists.txt +++ b/native/gua-runtime/CMakeLists.txt @@ -10,6 +10,12 @@ add_library(gua-runtime ${GUA_RUNTIME_LIBRARY_TYPE} add_library(gua::runtime ALIAS gua-runtime) +if(EMSCRIPTEN) + add_executable(gua-web-build-info tests/build_info_probe.cpp) + target_link_libraries(gua-web-build-info PRIVATE gua::runtime) + target_link_options(gua-web-build-info PRIVATE "-sENVIRONMENT=node" "-sWASM_ASYNC_COMPILATION=0") +endif() + if(GUA_DESKTOP_NATIVE AND GUA_BUILD_GODOT) # A distributed Godot addon contains per-platform Debug and Release # GDExtensions side by side. Embed the runtime so each library is diff --git a/native/gua-runtime/tests/build_info_probe.cpp b/native/gua-runtime/tests/build_info_probe.cpp new file mode 100644 index 00000000..6fac7508 --- /dev/null +++ b/native/gua-runtime/tests/build_info_probe.cpp @@ -0,0 +1,10 @@ +#include "gua/runtime.h" +#include +#include +int main() { + const int size=gua_runtime_copy_build_info_json(nullptr,0); + if(size<=0 || size>65536) return 1; + std::vector json(static_cast(size)); + if(gua_runtime_copy_build_info_json(json.data(),size)!=size) return 2; + std::cout << json.data() << '\n'; +} diff --git a/native/gua-runtime/tests/build_variant_tests.cpp b/native/gua-runtime/tests/build_variant_tests.cpp index 2efa6c01..8e78d0e7 100644 --- a/native/gua-runtime/tests/build_variant_tests.cpp +++ b/native/gua-runtime/tests/build_variant_tests.cpp @@ -32,6 +32,7 @@ int main(int argc,char** argv) { gua_begin_frame(c,"variant"); gua_register_node(c,"public","button","Public",{0,0,10,10},1,1); gua_register_node(c,"secret","button","Hidden secret",{0,0,10,10},0,1); + gua_register_node(c,"text","textbox","Public text",{0,0,10,10},1,1); gua_end_frame(c); const auto tree=json([&](char* out,int size){return gua_copy_ui_tree_json(c,out,size);}); assert((tree.find("Hidden secret")==std::string::npos)==player); @@ -68,6 +69,36 @@ int main(int argc,char** argv) { assert(consumed.kind==GUA_GAME_INPUT_CLEANUP && consumed.operation==GUA_GAME_INPUT_RELEASE_ALL); assert(gua_complete_game_input_request(c,consumed.request_id,1,0)); assert(gua_release_game_input_owner(c,owner)); + // Legacy polling must leave nonlegacy correlated completions intact. + gua_action_request_descriptor_t descriptor{sizeof(descriptor)}; + descriptor.action=GUA_ACTION_SET_VALUE; descriptor.node_id="text"; descriptor.value="updated"; + uint64_t request_id=0; + assert(gua_enqueue_action(c,&descriptor,&request_id)==GUA_ACTION_ACCEPTED && request_id); + gua_action_request_t request{sizeof(request)}; + assert(gua_consume_action_request(c,GUA_ACTION_SET_VALUE,"text",&request)); + gua_action_result_t result{sizeof(result),request_id,GUA_ACTION_SET_VALUE,GUA_ACTION_STATUS_SUCCEEDED,0,"text","updated",0}; + assert(gua_emit_action_result(c,&result)); + gua_event_t legacy{}; assert(!gua_poll_event(c,&legacy)); + gua_event_v3_t completion{sizeof(completion)}; + assert(gua_poll_event_v3_for_request(c,request_id,&completion)); + assert(completion.base.request_id==request_id && completion.base.action==GUA_ACTION_SET_VALUE); + assert(!gua_poll_event_v3_for_request(c,request_id,&completion)); + // Private requestless events cannot alter status, stale reset counts or discarded counts. + gua_reset_options_t events_reset{sizeof(events_reset),GUA_RESET_EVENTS,0,UINT64_MAX,GUA_RESET_FLAGS_VERSION_CURRENT}; + gua_reset_report_t before{sizeof(before)}, after{sizeof(after)}; + assert(gua_reset_context(c,&events_reset,&before)==GUA_RESET_ERROR_STALE_EPOCH); + assert(before.unconsumed_event_count==0); + assert(gua_emit_click(c,"secret")); + assert(gua_reset_context(c,&events_reset,&after)==GUA_RESET_ERROR_STALE_EPOCH); + assert(after.unconsumed_event_count==before.unconsumed_event_count && after.discarded_event_count==0); + gua_context_status_t status{sizeof(status)}; + assert(gua_get_context_status(c,&status) && status.unconsumed_event_count==0); + assert(gua_emit_click(c,"public")); + assert(gua_reset_context(c,&events_reset,&after)==GUA_RESET_ERROR_STALE_EPOCH); + assert(after.unconsumed_event_count==1); + events_reset.expected_session_epoch=0; + assert(gua_reset_context(c,&events_reset,&after)==GUA_RESET_SUCCEEDED); + assert(after.unconsumed_event_count==1 && after.discarded_event_count==1); } assert(gua_enqueue_click(c,"public")); assert(gua_consume_click_request(c,"public")); diff --git a/scripts/build-web-native.ps1 b/scripts/build-web-native.ps1 index c2370683..6da40d2d 100644 --- a/scripts/build-web-native.ps1 +++ b/scripts/build-web-native.ps1 @@ -5,10 +5,14 @@ param( [switch]$RuntimeOnly, [string]$Version = "0.0.0", [string]$BuildId = "development", - [string]$Generator = "" + [string]$Generator = "", + [ValidateSet('shipping','development','qa','ai-player')][string]$Flavor = 'development', + [ValidateSet('on','off')][string]$Remote = 'on' ) $ErrorActionPreference = "Stop" +if ($Flavor -ne 'development' -and $Configuration -ne 'Release') { throw 'Distribution Web variants require Release.' } +if (($Flavor -eq 'shipping' -and $Remote -ne 'off') -or ($Flavor -in 'qa','ai-player' -and $Remote -ne 'on')) { throw 'Invalid Web variant opt-in.' } $root = Split-Path -Parent $PSScriptRoot if ([string]::IsNullOrWhiteSpace($BuildDirectory)) { $BuildDirectory = "build/web-$($Configuration.ToLowerInvariant())" @@ -35,9 +39,9 @@ $configureArguments = @( "-S", $root, "-B", $build, "-G", $Generator, "-DCMAKE_BUILD_TYPE=$Configuration", "-DGUA_BUILD_EXAMPLES=OFF", - "-DGUA_DISTRIBUTION_FLAVOR=development", - "-DGUA_ENABLE_REMOTE_AUTOMATION=ON", - "-DGUA_STAGE_IN_SAMPLE=ON", + "-DGUA_DISTRIBUTION_FLAVOR=$Flavor", + "-DGUA_ENABLE_REMOTE_AUTOMATION=$(if ($Remote -eq 'on') {'ON'} else {'OFF'})", + "-DGUA_STAGE_IN_SAMPLE=$(if ($Flavor -eq 'development') {'ON'} else {'OFF'})", "-DGUA_BUILD_GODOT=$(if ($RuntimeOnly) { 'OFF' } else { 'ON' })", "-DGUA_GODOT_CONFIGURATION=$Configuration", "-DGUA_VERSION=$Version", @@ -60,16 +64,29 @@ if (-not $RuntimeOnly) { } } -$targets = @("gua-runtime") +$targets = @("gua-runtime", "gua-web-build-info") if (-not $RuntimeOnly) { $targets += "gua-godot" } cmake --build $build --target @targets if ($LASTEXITCODE -ne 0) { throw "Failed to build the Gua Web native targets." } +$identity = (& node (Join-Path $build 'native/gua-runtime/gua-web-build-info.js')) | ConvertFrom-Json +if ($LASTEXITCODE -or $identity.flavor -ne $Flavor -or $identity.remoteEnabled -ne ($Remote -eq 'on')) { throw 'Actual linked Web build identity mismatch.' } +$stage = Join-Path $build 'stage/unity-web' +New-Item -ItemType Directory -Force $stage | Out-Null +$files = @() +foreach ($entry in @(@{dir='gua-core';name='libgua-core.a'},@{dir='gua-runtime';name='libgua_runtime.a'})) { + $sourceFile = Join-Path $build "native/$($entry.dir)/$($entry.name)" + Copy-Item -LiteralPath $sourceFile -Destination $stage -Force + $files += @{path=$entry.name;sha256=(Get-FileHash -LiteralPath $sourceFile -Algorithm SHA256).Hash.ToLowerInvariant()} +} +@{schemaVersion=1;build=$identity;files=$files} | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath (Join-Path $stage 'gua-build-manifest.json') -Encoding utf8NoBOM + Write-Host "Unity WebGL runtime: $(Join-Path $build 'native/gua-runtime/libgua_runtime.a')" Write-Host "Unity WebGL core: $(Join-Path $build 'native/gua-core/libgua-core.a')" if (-not $RuntimeOnly) { $godotConfiguration = $Configuration.ToLowerInvariant() - $godotExtension = Join-Path $root "examples/godot-gdscript/addons/gua/bin/gua_godot.web.$godotConfiguration.wasm32.wasm" + $addon = if ($Flavor -eq 'development') { Join-Path $root 'examples/godot-gdscript/addons/gua' } else { Join-Path $build 'stage/addons/gua' } + $godotExtension = Join-Path $addon "bin/gua_godot.web.$godotConfiguration.wasm32.wasm" if (-not (Test-Path -LiteralPath $godotExtension -PathType Leaf)) { throw "Godot Web extension was not produced at the configuration-specific path: $godotExtension" } diff --git a/scripts/get-msvc-generator.ps1 b/scripts/get-msvc-generator.ps1 new file mode 100644 index 00000000..e8b0d965 --- /dev/null +++ b/scripts/get-msvc-generator.ps1 @@ -0,0 +1,10 @@ +$ErrorActionPreference = 'Stop' +$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio/Installer/vswhere.exe' +$version = (& $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationVersion) +if ($LASTEXITCODE -or -not $version) { throw 'No supported MSVC installation found.' } +switch (([version]$version).Major) { + 18 { 'Visual Studio 18 2026' } + 17 { 'Visual Studio 17 2022' } + 16 { 'Visual Studio 16 2019' } + default { throw "Unsupported Visual Studio: $version" } +} diff --git a/scripts/package-unity-upm-ci.ps1 b/scripts/package-unity-upm-ci.ps1 index 99e3e9d8..8efbb9fb 100644 --- a/scripts/package-unity-upm-ci.ps1 +++ b/scripts/package-unity-upm-ci.ps1 @@ -6,7 +6,9 @@ param( [string]$OutputDirectory, [string]$WebNativeDirectory, - [switch]$DesktopOnly + [switch]$DesktopOnly, + [ValidateSet('shipping','development','qa','ai-player')][string]$Flavor = 'development', + [ValidateSet('on','off')][string]$Remote = 'on' ) $ErrorActionPreference = "Stop" @@ -47,14 +49,19 @@ Copy-Item (Join-Path $plugins "Windows/x86_64/*") (Join-Path $artifact "Runtime/ Copy-Item (Join-Path $plugins "Linux/x86_64/*") (Join-Path $artifact "Runtime/Plugins/Linux/x86_64") Copy-Item (Join-Path $plugins "macOS/*") (Join-Path $artifact "Runtime/Plugins/macOS") if (-not $DesktopOnly) { + if ($Remote -eq 'on') { Copy-Item (Join-Path $root "bindings/unity/Runtime/Plugins/WebGL/GuaWebMcp.jslib") (Join-Path $artifact "Runtime/Plugins/WebGL") Copy-Item (Join-Path $root "bindings/unity/Runtime/Plugins/WebGL/GuaWebMcp.jslib.meta") (Join-Path $artifact "Runtime/Plugins/WebGL") + } foreach ($webLibrary in "libgua_runtime.a", "libgua-core.a") { $webLibraries = @(Get-ChildItem -LiteralPath $WebNativeDirectory -Recurse -File -Filter $webLibrary) if ($webLibraries.Count -ne 1) { throw "Expected one $webLibrary below '$WebNativeDirectory', found $($webLibraries.Count)." } Copy-Item -LiteralPath $webLibraries[0].FullName -Destination (Join-Path $artifact "Runtime/Plugins/WebGL") Copy-Item (Join-Path $root "scripts/unity-meta/$webLibrary.meta") (Join-Path $artifact "Runtime/Plugins/WebGL") } + $webManifests = @(Get-ChildItem -LiteralPath $WebNativeDirectory -Recurse -File -Filter 'gua-build-manifest.json') + if ($webManifests.Count -ne 1) { throw 'Expected one verified Web archive manifest.' } + Copy-Item -LiteralPath $webManifests[0].FullName -Destination (Join-Path $artifact 'Runtime/Plugins/WebGL') } $scriptAssemblies = Join-Path $project "Library/ScriptAssemblies" @@ -69,6 +76,7 @@ if (-not (Test-Path -LiteralPath $editorAssembly)) { throw "Unity editor assembl Copy-Item -LiteralPath $editorAssembly -Destination (Join-Path $artifact "Editor") Copy-Item (Join-Path $root "scripts/unity-meta/Gua.Unity.Editor.dll.meta") (Join-Path $artifact "Editor") +& (Join-Path $PSScriptRoot 'verify-unity-variant-package.ps1') -Directory $artifact -Flavor $Flavor -Remote $Remote -DesktopOnly:$DesktopOnly $staging = Join-Path $OutputDirectory "tgz-staging" if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force } New-Item -ItemType Directory -Force $staging | Out-Null diff --git a/scripts/stage-remote-build.ps1 b/scripts/stage-remote-build.ps1 index d813b13f..2ba26320 100644 --- a/scripts/stage-remote-build.ps1 +++ b/scripts/stage-remote-build.ps1 @@ -47,7 +47,8 @@ if ($GodotExtension) { } if ($UnityAssembly) { $managedIdentity = (& dotnet $probe managed $UnityAssembly) | ConvertFrom-Json - if ($LASTEXITCODE -or $managedIdentity.RemoteEnabled -ne ($Remote -eq 'on') -or $managedIdentity.PlayerOnly -ne ($Flavor -eq 'ai-player') -or $managedIdentity.remoteCommandHandler -ne ($Remote -eq 'on')) { throw 'Unity managed IL has the wrong remote variant.' } + if ($LASTEXITCODE -or $managedIdentity.RemoteEnabled -ne ($Remote -eq 'on') -or $managedIdentity.PlayerOnly -ne ($Flavor -eq 'ai-player') -or + $managedIdentity.remoteCommandHandler -ne ($Remote -eq 'on') -or $managedIdentity.remoteStartReference -ne ($Remote -eq 'on') -or $managedIdentity.webInstallImport -ne ($Remote -eq 'on')) { throw 'Unity managed IL has the wrong remote variant.' } } # All identity checks happen before any distribution file is written. New-Item -ItemType Directory -Force $output | Out-Null diff --git a/scripts/test-remote-build-config.ps1 b/scripts/test-remote-build-config.ps1 index 855e2f89..4bc9f395 100644 --- a/scripts/test-remote-build-config.ps1 +++ b/scripts/test-remote-build-config.ps1 @@ -10,9 +10,10 @@ $cases = @( $evidence = Join-Path $root ('build/cfg-' + [Guid]::NewGuid().ToString('N').Substring(0,8)) New-Item -ItemType Directory $evidence | Out-Null $caseNumber = 0 +$generator = & (Join-Path $PSScriptRoot 'get-msvc-generator.ps1') foreach ($case in $cases) { $caseNumber++ - $lines = @(cmake -S $root -B (Join-Path $evidence $caseNumber) -G 'Visual Studio 18 2026' -A x64 -DGUA_BUILD_GODOT=OFF -DGUA_BUILD_EXAMPLES=OFF @($case.Args) 2>&1) + $lines = @(cmake -S $root -B (Join-Path $evidence $caseNumber) -G $generator -A x64 -DGUA_BUILD_GODOT=OFF -DGUA_BUILD_EXAMPLES=OFF @($case.Args) 2>&1) $exitCode = $LASTEXITCODE $lines | Set-Content -LiteralPath (Join-Path $evidence ($case.Name + '.log')) if ($exitCode -eq 0 -or ($lines -join "`n") -notmatch $case.Reason) { throw "Configuration rejection did not detect $($case.Name)." } diff --git a/scripts/test-remote-build-variant.ps1 b/scripts/test-remote-build-variant.ps1 index b034a3db..211f7dbe 100644 --- a/scripts/test-remote-build-variant.ps1 +++ b/scripts/test-remote-build-variant.ps1 @@ -13,7 +13,8 @@ $root = Split-Path -Parent $PSScriptRoot if (-not $BuildDirectory) { $BuildDirectory = Join-Path $root "build/remote-$Flavor-$Remote-$Configuration" } $build = [IO.Path]::GetFullPath($BuildDirectory) $remoteValue = if ($Remote -eq 'on') { 'ON' } else { 'OFF' } -$configure = @('-S',$root,'-B',$build,'-G','Visual Studio 18 2026','-A','x64', +$generator = & (Join-Path $PSScriptRoot 'get-msvc-generator.ps1') +$configure = @('-S',$root,'-B',$build,'-G',$generator,'-A','x64', "-DGUA_DISTRIBUTION_FLAVOR=$Flavor","-DGUA_ENABLE_REMOTE_AUTOMATION=$remoteValue", '-DGUA_STAGE_IN_SAMPLE=OFF','-DGUA_BUILD_EXAMPLES=OFF','-DBUILD_TESTING=ON', "-DGUA_BUILD_GODOT=$(if ($WithGodot) { 'ON' } else { 'OFF' })","-DGUA_GODOT_CONFIGURATION=$Configuration") diff --git a/scripts/verify-dotnet-native-packages.ps1 b/scripts/verify-dotnet-native-packages.ps1 index 47254e34..a8762e98 100644 --- a/scripts/verify-dotnet-native-packages.ps1 +++ b/scripts/verify-dotnet-native-packages.ps1 @@ -22,6 +22,21 @@ function Assert-PackageEntries([string]$PackageName, [string[]]$ExpectedEntries) if ($entries -notcontains $expected) { throw "$PackageName is missing native package entry: $expected" } + $component = if ($PackageName -eq 'Gua.Core') {'core'} else {'runtime'} + $manifestName = $expected.Substring(0,$expected.LastIndexOf('/')+1) + "gua-$component-build-manifest.json" + $manifestEntry = @($archive.Entries | Where-Object FullName -CEQ $manifestName) + if ($manifestEntry.Count -ne 1) { throw "$PackageName lacks a unique compiled identity: $manifestName" } + $reader = [IO.StreamReader]::new($manifestEntry[0].Open()) + try { $manifest = $reader.ReadToEnd() | ConvertFrom-Json } finally { $reader.Dispose() } + $identity = $manifest.build | ConvertTo-Json -Depth 12 -Compress + if ($script:PackageBuildIdentity -and $script:PackageBuildIdentity -ne $identity) { throw 'NuGet native components have different compiled identities.' } + $script:PackageBuildIdentity = $identity + $nativeEntry = @($archive.Entries | Where-Object FullName -CEQ $expected) + $hashEntry = @($manifest.files | Where-Object path -CEQ ([IO.Path]::GetFileName($expected))) + if ($nativeEntry.Count -ne 1 -or $hashEntry.Count -ne 1) { throw 'Missing or duplicate NuGet native identity.' } + $stream = $nativeEntry[0].Open(); $sha = [Security.Cryptography.SHA256]::Create() + try { $actual = [Convert]::ToHexString($sha.ComputeHash($stream)).ToLowerInvariant() } finally { $stream.Dispose(); $sha.Dispose() } + if ($actual -ne $hashEntry[0].sha256) { throw "NuGet native bytes differ from manifest: $expected" } } } finally { $archive.Dispose() diff --git a/scripts/verify-remote-build.ps1 b/scripts/verify-remote-build.ps1 index bc1ea4f3..e89da6cc 100644 --- a/scripts/verify-remote-build.ps1 +++ b/scripts/verify-remote-build.ps1 @@ -32,7 +32,8 @@ foreach ($file in $manifest.files) { } if ($file.path -eq 'Gua.Unity.dll') { $identity = (& dotnet $probe managed $path) | ConvertFrom-Json - if ($LASTEXITCODE -or $identity.RemoteEnabled -ne ($Remote -eq 'on') -or $identity.PlayerOnly -ne ($Flavor -eq 'ai-player') -or $identity.remoteCommandHandler -ne ($Remote -eq 'on')) { throw 'Packaged Unity managed IL has the wrong remote variant.' } + if ($LASTEXITCODE -or $identity.RemoteEnabled -ne ($Remote -eq 'on') -or $identity.PlayerOnly -ne ($Flavor -eq 'ai-player') -or + $identity.remoteCommandHandler -ne ($Remote -eq 'on') -or $identity.remoteStartReference -ne ($Remote -eq 'on') -or $identity.webInstallImport -ne ($Remote -eq 'on')) { throw 'Packaged Unity managed IL has the wrong remote variant.' } } if ($Remote -eq 'off' -and $file.path -match '(?i)(gua_webmcp_bridge\.gd|GuaWebMcp\.jslib|gua[-_]ws[-_]bridge)') { throw 'Remote implementation found in a disabled package.' } } diff --git a/scripts/verify-unity-variant-package.ps1 b/scripts/verify-unity-variant-package.ps1 new file mode 100644 index 00000000..db66378f --- /dev/null +++ b/scripts/verify-unity-variant-package.ps1 @@ -0,0 +1,38 @@ +param( + [Parameter(Mandatory)][string]$Directory, + [Parameter(Mandatory)][ValidateSet('shipping','development','qa','ai-player')][string]$Flavor, + [Parameter(Mandatory)][ValidateSet('on','off')][string]$Remote, + [switch]$DesktopOnly +) +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +dotnet build (Join-Path $root 'tools/Gua.ArtifactProbe/Gua.ArtifactProbe.csproj') -c Release --nologo -v quiet -m:1 -nr:false +if ($LASTEXITCODE) { throw 'Cannot build Unity IL probe.' } +$probe = Join-Path $root 'tools/Gua.ArtifactProbe/bin/Release/net10.0/Gua.ArtifactProbe.dll' +$compiled = (& dotnet $probe managed (Join-Path $Directory 'Runtime/Plugins/Managed/Gua.Unity.dll')) | ConvertFrom-Json +if ($LASTEXITCODE) { throw 'Cannot inspect Unity IL.' } +$enabled = $Remote -eq 'on'; $ceiling = if (-not $enabled) {'none'} elseif ($Flavor -eq 'ai-player') {'player'} else {'debug'} +if (($Flavor -eq 'shipping' -and $enabled) -or ($Flavor -in 'qa','ai-player' -and -not $enabled) -or + $compiled.RemoteEnabled -ne $enabled -or $compiled.PlayerOnly -ne ($ceiling -eq 'player') -or + $compiled.remoteCommandHandler -ne $enabled -or $compiled.remoteStartReference -ne $enabled -or $compiled.webInstallImport -ne $enabled) { throw 'Unity IL has a different compiled capability.' } +$layouts = @(@{path='Windows/x86_64';names=@('gua.dll','gua_runtime.dll')},@{path='Linux/x86_64';names=@('libgua.so','libgua_runtime.so')},@{path='macOS';names=@('libgua.dylib','libgua_runtime.dylib')}) +if (-not $DesktopOnly) { $layouts += @{path='WebGL';names=@('libgua-core.a','libgua_runtime.a')} } +$source = $null; $build = $null +foreach ($layout in $layouts) { + $native = Join-Path $Directory "Runtime/Plugins/$($layout.path)" + $manifest = Get-Content -LiteralPath (Join-Path $native 'gua-build-manifest.json') -Raw | ConvertFrom-Json + $info = $manifest.build + if ($manifest.schemaVersion -ne 1 -or $info.flavor -ne $Flavor -or $info.remoteEnabled -ne $enabled -or + $info.remotePermissionCeiling -ne $ceiling -or $info.sourceCommit -notmatch '^[0-9a-f]{40}$' -or + [string]::IsNullOrWhiteSpace($info.buildId) -or ($Flavor -ne 'development' -and $info.configuration -ne 'Release')) { throw "Wrong Unity native variant: $($layout.path)" } + if ($source -and ($info.sourceCommit -ne $source -or $info.buildId -ne $build)) { throw 'Unity platform assets came from different builds.' } + $source=$info.sourceCommit; $build=$info.buildId + foreach ($name in $layout.names) { + $entry=@($manifest.files | Where-Object path -CEQ $name) + if ($entry.Count -ne 1 -or (Get-FileHash -LiteralPath (Join-Path $native $name) -Algorithm SHA256).Hash.ToLowerInvariant() -ne $entry[0].sha256) { throw "Unity native hash mismatch: $($layout.path)/$name" } + } +} +$js = @(Get-ChildItem -LiteralPath $Directory -Recurse -File -Filter '*.jslib') +if (($DesktopOnly -or -not $enabled) -and $js.Count -ne 0) { throw 'This Unity package cannot include remote WebMCP JavaScript.' } +if (-not $DesktopOnly -and $enabled -and ($js.Count -ne 1 -or $js[0].Name -ne 'GuaWebMcp.jslib')) { throw 'Missing or duplicate Unity WebMCP JavaScript.' } +Write-Host "Verified Unity IL, native identities and hashes: $Flavor / $Remote" diff --git a/tools/Gua.ArtifactProbe/Program.cs b/tools/Gua.ArtifactProbe/Program.cs index bb03c3cc..b7abb88b 100644 --- a/tools/Gua.ArtifactProbe/Program.cs +++ b/tools/Gua.ArtifactProbe/Program.cs @@ -23,6 +23,8 @@ using var stream=File.OpenRead(args[1]); using var pe=new PEReader(stream); var reader=pe.GetMetadataReader(); var values=new Dictionary(); + values["remoteStartReference"]=reader.MemberReferences.Any(h=>reader.GetString(reader.GetMemberReference(h).Name)=="StartInspectorBridge"); + values["webInstallImport"]=reader.MethodDefinitions.Any(h=>reader.GetString(reader.GetMethodDefinition(h).Name)=="GuaUnityWebInstall"); foreach(var handle in reader.TypeDefinitions) { var type=reader.GetTypeDefinition(handle); if(reader.GetString(type.Namespace)!="Gua.Unity") continue; From 9df9192f114dde7745ddf7146411200d464e7696 Mon Sep 17 00:00:00 2001 From: link1345 Date: Sun, 1 Nov 2026 11:21:08 +0900 Subject: [PATCH 3/5] =?UTF-8?q?=E5=AE=9F=20Unity=20Player=20=E3=81=A8=20We?= =?UTF-8?q?bAssembly=20=E3=81=AE=20variant=20=E6=A4=9C=E8=A8=BC=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/remote-build-variants.yml | 33 +++++- bindings/unity/Editor/GuaVariantBuildGuard.cs | 2 +- .../unity/Runtime/GuaUnityCompiledBuild.cs | 3 + docs/remote-build-variants-evidence.md | 22 ++++ native/gua-runtime/CMakeLists.txt | 4 + .../gua-runtime/tests/build_variant_tests.cpp | 11 +- protocol/specs/protocol.md | 2 +- scripts/build-web-native.ps1 | 4 +- scripts/test-remote-build-variant.ps1 | 5 +- scripts/test-unity-remote-build.ps1 | 107 ++++++++++++++++++ scripts/verify-remote-build-graph.ps1 | 11 +- 11 files changed, 194 insertions(+), 10 deletions(-) create mode 100644 scripts/test-unity-remote-build.ps1 diff --git a/.github/workflows/remote-build-variants.yml b/.github/workflows/remote-build-variants.yml index 4332fa0c..622b87b5 100644 --- a/.github/workflows/remote-build-variants.yml +++ b/.github/workflows/remote-build-variants.yml @@ -31,7 +31,7 @@ jobs: - name: Build and accept the actual variant shell: pwsh run: | - $args = @{ Flavor = '${{ matrix.flavor }}'; Remote = '${{ matrix.remote }}'; Configuration = '${{ matrix.configuration }}' } + $args = @{ Flavor = '${{ matrix.flavor }}'; Remote = '${{ matrix.remote }}'; Configuration = '${{ matrix.configuration }}'; BuildParallel = 4 } if ('${{ matrix.godot }}' -eq 'true') { $args.WithGodot = $true } scripts/test-remote-build-variant.ps1 @args - name: Reject invalid configuration combinations @@ -118,3 +118,34 @@ jobs: name: static-web-${{ matrix.flavor }}-${{ github.sha }} path: build/web-release/stage/unity-web if-no-files-found: error + portable-distribution: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + variant: + - { flavor: shipping, remote: 'off', optin: 'OFF' } + - { flavor: ai-player, remote: 'on', optin: 'ON' } + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - shell: pwsh + run: | + cmake -S . -B build/distribution -DCMAKE_BUILD_TYPE=Release -DGUA_BUILD_GODOT=OFF -DGUA_BUILD_EXAMPLES=OFF -DGUA_DISTRIBUTION_FLAVOR=${{ matrix.variant.flavor }} -DGUA_ENABLE_REMOTE_AUTOMATION=${{ matrix.variant.optin }} -DGUA_BUILD_ID=${{ github.sha }} + if ($LASTEXITCODE) { exit $LASTEXITCODE } + cmake --build build/distribution --target gua gua-build-variant-tests --parallel 2 + if ($LASTEXITCODE) { exit $LASTEXITCODE } + ctest --test-dir build/distribution -R '^gua-build-variant-tests$' --output-on-failure + if ($LASTEXITCODE) { exit $LASTEXITCODE } + scripts/stage-remote-build.ps1 -BuildDirectory build/distribution -OutputDirectory artifacts/package -Flavor '${{ matrix.variant.flavor }}' -Remote '${{ matrix.variant.remote }}' + - uses: actions/upload-artifact@v4 + if: always() + with: + name: portable-${{ matrix.os }}-${{ matrix.variant.flavor }}-${{ github.sha }} + path: | + artifacts/package + build/distribution/Testing/Temporary/LastTest.log + if-no-files-found: error diff --git a/bindings/unity/Editor/GuaVariantBuildGuard.cs b/bindings/unity/Editor/GuaVariantBuildGuard.cs index c3321953..42e5b4be 100644 --- a/bindings/unity/Editor/GuaVariantBuildGuard.cs +++ b/bindings/unity/Editor/GuaVariantBuildGuard.cs @@ -78,7 +78,7 @@ private static void ValidateIdentity(JsonElement info, string expected) (expected != "development" && info.GetProperty("configuration").GetString() != "Release") || (expected == "shipping" && remote) || (expected != "shipping" && expected != "development" && !remote) || ceiling != (!remote ? "none" : expected == "ai-player" ? "player" : "debug") || - remote != GuaUnityCompiledBuild.RemoteEnabled || (ceiling == "player") != GuaUnityCompiledBuild.PlayerOnly) + remote != GuaUnityCompiledBuild.IsRemoteEnabled() || (ceiling == "player") != GuaUnityCompiledBuild.IsPlayerOnly()) throw new InvalidDataException("Native/managed build purpose or compiled ceiling mismatch."); } diff --git a/bindings/unity/Runtime/GuaUnityCompiledBuild.cs b/bindings/unity/Runtime/GuaUnityCompiledBuild.cs index edf64d54..db94237f 100644 --- a/bindings/unity/Runtime/GuaUnityCompiledBuild.cs +++ b/bindings/unity/Runtime/GuaUnityCompiledBuild.cs @@ -3,6 +3,9 @@ namespace Gua.Unity /// Compile-time managed variant, independently checked against native identity. public static class GuaUnityCompiledBuild { + // Keep Editor binaries reusable: referencing const fields would bake the producer's variant into them. + public static bool IsRemoteEnabled() => RemoteEnabled; + public static bool IsPlayerOnly() => PlayerOnly; #if GUA_REMOTE_AUTOMATION public const bool RemoteEnabled = true; #else diff --git a/docs/remote-build-variants-evidence.md b/docs/remote-build-variants-evidence.md index 98ac0ac9..b6c7710d 100644 --- a/docs/remote-build-variants-evidence.md +++ b/docs/remote-build-variants-evidence.md @@ -57,3 +57,25 @@ Web install import がすべて absent、Development/AI Player ではすべて p `artifacts/remote-validation/unity-editor-guard.log` の最初の失敗は修正後の成功ログへ更新した。 追加した Web static probe/UPM多プラットフォーム検査と新 CI consumer job は、最終 HEAD CI の結果で判定する。 + +## 実 consumer と回帰の追加検証 + +`b4c8d74` 上のローカル作業ツリーで、Unity 6000.5.3f1 の既存ライセンスと +隔離した新規 project を使い Shipping Release Player のビルド/実行を確認した。 +ログと実結果は `build/unity-variant-shipping-20261101T021555639/{editor.log,player.log,Player/result.json}`。 +検査するのは実配布 DLL の local API / Player 投影 / immutable identity / bridge 可否であり、 +GuaUnityRuntime の全 UI 反映経路の合格と拡大解釈しない。 +fixture の初期失敗は Editor 用 InputSystem DLL の Player 混入と依存 package 自体の NUnit テストだった。 +既存 package source を fixture 内へコピーして Player 用にコンパイルし、未使用の package test は除いた。 +AI Player の実ビルドで Editor guard の const inlining も検出し、runtime accessor 呼出しに修正した。 +同一 Editor guard と4つの native/managed variant の最終確認は実行中。 + +通常 .NET selector/remote/Trace 回帰は `selector-regression-prepared.log` にて465/465合格。 +準備前の実行は450/465で、未構築の Trace viewer/MCP と deadline 失敗を含んだため合格とはしない。 +MCP/Trace を lockfile に従って構築し、Bun の PATH を揃えた実行で全件合格した。 +Windows Shipping の native runtime / embedded Godot object・link graph と最終 DLL の +socket DLL 依存不在も再確認した。graph 検査は保存済み証拠 project を再探索しないようにし、再実行も合格。 + +Draft PR は https://github.com/gua-project/gua/pull/186 。初回 CI は `b4c8d74` の PR merge snapshot。 +Web static identity の4構成、標準回帰の Windows/Linux/macOS native/managed lane は合格した。 +新たな実 WebAssembly variant acceptance と portable Shipping/AI Player CI は追加後 HEAD で評価する。 diff --git a/native/gua-runtime/CMakeLists.txt b/native/gua-runtime/CMakeLists.txt index 0dc0c8a8..21451386 100644 --- a/native/gua-runtime/CMakeLists.txt +++ b/native/gua-runtime/CMakeLists.txt @@ -14,6 +14,10 @@ if(EMSCRIPTEN) add_executable(gua-web-build-info tests/build_info_probe.cpp) target_link_libraries(gua-web-build-info PRIVATE gua::runtime) target_link_options(gua-web-build-info PRIVATE "-sENVIRONMENT=node" "-sWASM_ASYNC_COMPILATION=0") + add_executable(gua-web-variant-tests tests/build_variant_tests.cpp) + target_link_libraries(gua-web-variant-tests PRIVATE gua::runtime) + target_compile_options(gua-web-variant-tests PRIVATE -UNDEBUG) + target_link_options(gua-web-variant-tests PRIVATE "-sENVIRONMENT=node" "-sWASM_ASYNC_COMPILATION=0") endif() if(GUA_DESKTOP_NATIVE AND GUA_BUILD_GODOT) diff --git a/native/gua-runtime/tests/build_variant_tests.cpp b/native/gua-runtime/tests/build_variant_tests.cpp index 8e78d0e7..0040029d 100644 --- a/native/gua-runtime/tests/build_variant_tests.cpp +++ b/native/gua-runtime/tests/build_variant_tests.cpp @@ -21,6 +21,11 @@ static void environment(const char* value) { int main(int argc,char** argv) { assert(argc==4); const bool remote=std::string(argv[2])=="on", player=std::string(argv[3])=="player"; +#ifdef __EMSCRIPTEN__ + const bool websocket=false; // Web variants expose the same-page binding, never native sockets. +#else + const bool websocket=remote; +#endif const auto info=json(gua_runtime_copy_build_info_json); assert(info==json(gua_copy_build_info_json)); assert(info.find("\"flavor\":\""+std::string(argv[1])+"\"")!=std::string::npos); @@ -109,9 +114,9 @@ int main(int argc,char** argv) { if(player) { assert(!gua_runtime_set_observation_profile(r,0)); assert(gua_runtime_get_observation_profile(r)==1); } assert(!gua_runtime_start_inspector_bridge(r,0)); const int port=19461; - assert((gua_runtime_start_inspector_bridge(r,port)!=0)==remote); - assert((gua_runtime_inspector_bridge_running(r)!=0)==remote); - assert((std::strlen(gua_runtime_inspector_bridge_url(r))!=0)==remote); + assert((gua_runtime_start_inspector_bridge(r,port)!=0)==websocket); + assert((gua_runtime_inspector_bridge_running(r)!=0)==websocket); + assert((std::strlen(gua_runtime_inspector_bridge_url(r))!=0)==websocket); gua_runtime_stop_inspector_bridge(r); gua_runtime_stop_inspector_bridge(r); assert(!gua_runtime_inspector_bridge_running(r)); assert(std::strlen(gua_runtime_inspector_bridge_url(r))==0); diff --git a/protocol/specs/protocol.md b/protocol/specs/protocol.md index cdd44222..e44b5ff4 100644 --- a/protocol/specs/protocol.md +++ b/protocol/specs/protocol.md @@ -284,7 +284,7 @@ World v1 provides no actions, pathfinding, teleportation, or arbitrary host meth ## Agent projection policy v1 -Capability `agent_projection_v1` applies the host-owned `debug` or `player` observation profile before data reaches a transport. Existing C ABI entry points remain debug-compatible; runtimes use the additive profile-aware tree, query, diagnostics, and action entry points. A runtime may narrow from debug to player before starting its bridge and cannot be elevated again by a command or tool argument. +Capability `agent_projection_v1` applies the host-owned `debug` or `player` observation profile before data reaches a transport. Existing C ABI entry points remain debug-compatible except in a compiled AI-player variant, where unscoped reads use Player and explicit Debug reads fail; runtimes use the additive profile-aware tree, query, diagnostics, and action entry points. A runtime may narrow from debug to player before starting its bridge and cannot be elevated again by a command or tool argument. Debug returns the complete registered UI and World snapshots. Player UI `auto` nodes require effective visibility through every ancestor. Player World `auto` objects require host-defined semantic visibility and active state through every ancestor; render visibility is not a substitute. A `private` ancestor removes its complete subtree, and projection never reparents descendants. diff --git a/scripts/build-web-native.ps1 b/scripts/build-web-native.ps1 index 6da40d2d..379f43c9 100644 --- a/scripts/build-web-native.ps1 +++ b/scripts/build-web-native.ps1 @@ -64,13 +64,15 @@ if (-not $RuntimeOnly) { } } -$targets = @("gua-runtime", "gua-web-build-info") +$targets = @("gua-runtime", "gua-web-build-info", "gua-web-variant-tests") if (-not $RuntimeOnly) { $targets += "gua-godot" } cmake --build $build --target @targets if ($LASTEXITCODE -ne 0) { throw "Failed to build the Gua Web native targets." } $identity = (& node (Join-Path $build 'native/gua-runtime/gua-web-build-info.js')) | ConvertFrom-Json if ($LASTEXITCODE -or $identity.flavor -ne $Flavor -or $identity.remoteEnabled -ne ($Remote -eq 'on')) { throw 'Actual linked Web build identity mismatch.' } +& node (Join-Path $build 'native/gua-runtime/gua-web-variant-tests.js') $Flavor $Remote $identity.remotePermissionCeiling +if ($LASTEXITCODE) { throw 'Actual WebAssembly profile/input/reset acceptance failed.' } $stage = Join-Path $build 'stage/unity-web' New-Item -ItemType Directory -Force $stage | Out-Null $files = @() diff --git a/scripts/test-remote-build-variant.ps1 b/scripts/test-remote-build-variant.ps1 index 211f7dbe..298e15c9 100644 --- a/scripts/test-remote-build-variant.ps1 +++ b/scripts/test-remote-build-variant.ps1 @@ -6,7 +6,8 @@ param( [string]$GodotCppSource, [string]$GodotBuildProfile, [string]$PythonExecutable, - [string]$BuildDirectory + [string]$BuildDirectory, + [ValidateRange(1,16)][int]$BuildParallel = 1 ) $ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot @@ -28,7 +29,7 @@ cmake @configure if ($LASTEXITCODE) { throw 'Variant configure failed.' } $targets = @('gua','gua-build-variant-tests') if ($WithGodot) { $targets += 'gua-godot' } -cmake --build $build --config $Configuration --target @targets --parallel 1 -- /nr:false +cmake --build $build --config $Configuration --target @targets --parallel $BuildParallel -- /nr:false if ($LASTEXITCODE) { throw 'Variant compilation failed.' } $previousPath = $env:PATH try { diff --git a/scripts/test-unity-remote-build.ps1 b/scripts/test-unity-remote-build.ps1 new file mode 100644 index 00000000..0c89cbb9 --- /dev/null +++ b/scripts/test-unity-remote-build.ps1 @@ -0,0 +1,107 @@ +param( + [Parameter(Mandatory)][string]$UnityExecutable, + [Parameter(Mandatory)][string]$ManagedDirectory, + [Parameter(Mandatory)][string]$EditorAssembly, + [Parameter(Mandatory)][string]$NativeDirectory, + [Parameter(Mandatory)][string]$UnityPackageCache, + [Parameter(Mandatory)][ValidateSet('shipping','development','qa','ai-player')][string]$Flavor, + [Parameter(Mandatory)][ValidateSet('on','off')][string]$Remote +) +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$fixture = Join-Path $root ('build/unity-variant-' + $Flavor + '-' + [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssfff')) +$managed = Join-Path $fixture 'Assets/Plugins/Managed'; $native = Join-Path $fixture 'Assets/Plugins/Native'; $editor = Join-Path $fixture 'Assets/Editor' +New-Item -ItemType Directory -Force $managed,$native,$editor,(Join-Path $fixture 'Packages'),(Join-Path $fixture 'ProjectSettings') | Out-Null +$dependencies=@{'com.unity.modules.uielements'='1.0.0';'com.unity.modules.screencapture'='1.0.0';'com.unity.modules.imageconversion'='1.0.0'} +foreach ($packageName in 'com.unity.inputsystem','com.unity.ugui') { + $packageSource=@(Get-ChildItem -LiteralPath $UnityPackageCache -Directory -Filter "$packageName@*") + if ($packageSource.Count -ne 1) { throw "Expected one existing $packageName source package." } + Copy-Item -LiteralPath $packageSource[0].FullName -Destination (Join-Path $fixture "Packages/$packageName") -Recurse + # This consumer uses package runtime/editor sources, not their NUnit test projects. + $copiedTests=[IO.Path]::GetFullPath((Join-Path $fixture "Packages/$packageName/Tests")) + if (-not $copiedTests.StartsWith([IO.Path]::GetFullPath($fixture)+[IO.Path]::DirectorySeparatorChar,[StringComparison]::OrdinalIgnoreCase)) { throw 'Unsafe fixture test path.' } + if (Test-Path -LiteralPath $copiedTests) { Remove-Item -LiteralPath $copiedTests -Recurse -Force } + $dependencies[$packageName]="file:$packageName" +} +@{dependencies=$dependencies} | ConvertTo-Json | Set-Content (Join-Path $fixture 'Packages/manifest.json') -Encoding utf8NoBOM +'m_EditorVersion: 6000.5.3f1' | Set-Content (Join-Path $fixture 'ProjectSettings/ProjectVersion.txt') -Encoding utf8NoBOM +foreach ($assembly in 'Gua.Core.dll','Gua.Runtime.dll','Gua.Unity.dll') { + Copy-Item -LiteralPath (Join-Path $ManagedDirectory $assembly) -Destination $managed +} +& (Join-Path $PSScriptRoot 'copy-unity-managed-closure.ps1') -AssetsFile (Join-Path $root 'bindings/dotnet/src/Gua.Runtime/obj/project.assets.json') -TargetFramework netstandard2.1 -Destination $managed +Copy-Item -LiteralPath $EditorAssembly -Destination (Join-Path $editor 'Gua.Unity.Editor.dll') +Copy-Item -LiteralPath (Join-Path $root 'scripts/unity-meta/Gua.Unity.Editor.dll.meta') -Destination $editor +foreach ($library in 'gua.dll','gua_runtime.dll') { + Copy-Item -LiteralPath (Join-Path $NativeDirectory $library) -Destination $native + Copy-Item -LiteralPath (Join-Path $root "scripts/unity-meta/$library.meta") -Destination $native +} +Copy-Item -LiteralPath (Join-Path $NativeDirectory 'gua-build-manifest.json') -Destination $native +@' +using System; +using System.IO; +using System.Text.Json; +using Gua.Core; +using Gua.Runtime; +using Gua.Unity; +using UnityEngine; +public static class GuaVariantConsumer { + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + static void Run() { + try { + using var runtime=new GuaRuntime(); + using var identity=JsonDocument.Parse(runtime.GetBuildInfoJson()); + var info=identity.RootElement; + bool remote=info.GetProperty("remoteEnabled").GetBoolean(); + bool player=info.GetProperty("remotePermissionCeiling").GetString()=="player"; + if(remote!=GuaUnityCompiledBuild.RemoteEnabled || player!=GuaUnityCompiledBuild.PlayerOnly) throw new Exception("Unity IL/native mismatch"); + runtime.BeginFrame("unity-consumer"); + runtime.RegisterNode(new GuaNodeDescriptor("public","button","Public",new GuaBounds(0,0,10,10))); + runtime.RegisterNode(new GuaNodeDescriptor("secret","button","Private",new GuaBounds(0,0,10,10),AgentPolicy:new GuaAgentPolicy(GuaAgentExposure.Private))); + runtime.EndFrame(); + if(runtime.GetUiTreeJson().Contains("Private")==player) throw new Exception("Player projection mismatch"); + if(player) { + bool rejected=false; + try {runtime.SetObservationProfile(GuaObservationProfile.Debug);} catch(InvalidOperationException){rejected=true;} + if(!rejected) throw new Exception("AI Player elevated"); + } + if(runtime.StartInspectorBridge(19463)!=remote) throw new Exception("Bridge availability mismatch"); + runtime.StopInspectorBridge(); + File.WriteAllText(Path.Combine(Application.dataPath,"..","result.json"),runtime.GetBuildInfoJson()); + Debug.Log("GUA_UNITY_VARIANT_PASS"); Application.Quit(0); + } catch(Exception error) {Debug.LogException(error); Application.Quit(1);} + } +} +'@ | Set-Content (Join-Path $fixture 'Assets/GuaVariantConsumer.cs') -Encoding utf8NoBOM +@' +using System; +using System.IO; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEditor.Build; +using UnityEditor.Build.Reporting; +public static class GuaVariantFixtureBuild { + public static void Run() { + var scene=EditorSceneManager.NewScene(NewSceneSetup.EmptyScene,NewSceneMode.Single); + EditorSceneManager.SaveScene(scene,"Assets/Consumer.unity"); + PlayerSettings.SetScriptingBackend(NamedBuildTarget.Standalone,ScriptingImplementation.Mono2x); + var report=BuildPipeline.BuildPlayer(new BuildPlayerOptions {scenes=new[]{"Assets/Consumer.unity"},target=BuildTarget.StandaloneWindows64, + locationPathName="Player/Consumer.exe",options=Environment.GetEnvironmentVariable("GUA_BUILD_VARIANT")=="development" ? BuildOptions.Development : BuildOptions.None}); + if(report.summary.result!=BuildResult.Succeeded) throw new Exception("Variant Player build failed"); + } +} +'@ | Set-Content (Join-Path $editor 'GuaVariantFixtureBuild.cs') -Encoding utf8NoBOM +$previousVariant=$env:GUA_BUILD_VARIANT; $previousProfile=$env:GUA_OBSERVATION_PROFILE +try { + $env:GUA_BUILD_VARIANT=$Flavor; $env:GUA_OBSERVATION_PROFILE='' + $buildLog=Join-Path $fixture 'editor.log' + $process=Start-Process -FilePath $UnityExecutable -ArgumentList @('-batchmode','-nographics','-quit','-projectPath',"`"$fixture`"",'-executeMethod','GuaVariantFixtureBuild.Run','-logFile',"`"$buildLog`"") -WindowStyle Hidden -PassThru + if (-not $process.WaitForExit(600000)) { & taskkill.exe /PID $process.Id /T /F | Out-Null; throw "Unity build timed out: $buildLog" } + if ($process.ExitCode -ne 0 -or -not (Test-Path (Join-Path $fixture 'Player/Consumer.exe'))) { throw "Unity Player build failed ($($process.ExitCode)): $buildLog" } + $playerLog=Join-Path $fixture 'player.log' + $process=Start-Process -FilePath (Join-Path $fixture 'Player/Consumer.exe') -ArgumentList @('-batchmode','-nographics','-logFile',"`"$playerLog`"") -WindowStyle Hidden -PassThru + if (-not $process.WaitForExit(45000)) { & taskkill.exe /PID $process.Id /T /F | Out-Null; throw "Unity consumer timed out: $playerLog" } + $result=Get-Content -LiteralPath (Join-Path $fixture 'Player/result.json') -Raw | ConvertFrom-Json + if ($process.ExitCode -ne 0 -or $result.flavor -ne $Flavor -or $result.remoteEnabled -ne ($Remote -eq 'on') -or + (Get-Content $playerLog -Raw) -notmatch 'GUA_UNITY_VARIANT_PASS') { throw "Unity consumer failed: $playerLog" } + Write-Host "Actual Unity Player accepted $Flavor / $Remote : $fixture" +} finally {$env:GUA_BUILD_VARIANT=$previousVariant; $env:GUA_OBSERVATION_PROFILE=$previousProfile} diff --git a/scripts/verify-remote-build-graph.ps1 b/scripts/verify-remote-build-graph.ps1 index 47b6e4c3..0309d442 100644 --- a/scripts/verify-remote-build-graph.ps1 +++ b/scripts/verify-remote-build-graph.ps1 @@ -8,7 +8,7 @@ $build = [IO.Path]::GetFullPath($BuildDirectory) $header = Get-Content -LiteralPath (Join-Path $build 'generated/gua/build_config.h') -Raw $expected = if ($Remote -eq 'on') { 1 } else { 0 } if ($header -notmatch "(?m)^#define GUA_COMPILED_REMOTE $expected\r?$") { throw 'Wrong generated compile gate.' } -$projects = @(Get-ChildItem -LiteralPath $build -Recurse -Filter '*.vcxproj') +$projects = @(Get-ChildItem -LiteralPath (Join-Path $build 'native') -Recurse -Filter '*.vcxproj') if (-not $projects.Count) { throw 'This object inspection currently requires the Windows MSVC generator.' } $bridge = @($projects | Where-Object Name -EQ 'gua-ws-bridge.vcxproj') if (($bridge.Count -eq 1) -ne ($Remote -eq 'on')) { throw 'Wrong transport target/link graph (including stale targets).' } @@ -36,4 +36,13 @@ foreach ($project in $runtimeProjects) { Copy-Item -LiteralPath $project.FullName -Destination $evidence -Force } Copy-Item -LiteralPath (Join-Path $build 'generated/gua/build_config.h') -Destination $evidence -Force +$binaries = @(Join-Path $build "native/gua-runtime/$Configuration/gua_runtime.dll") +$godot = @(Get-ChildItem -LiteralPath (Join-Path $build 'stage') -Recurse -Filter 'gua_godot.windows.*.dll' -File -ErrorAction SilentlyContinue) +$binaries += @($godot | ForEach-Object FullName) +foreach ($binary in $binaries) { + $dependencies = @(& $dumpbin /dependents $binary) + if ($LASTEXITCODE) { throw "Dependency inspection failed: $binary" } + $dependencies | Set-Content -LiteralPath (Join-Path $evidence ((Split-Path $binary -Leaf) + '-dependencies.txt')) + if ($Remote -eq 'off' -and ($dependencies -match '^\s*WS2_32\.dll\s*$').Count) { throw 'Socket transport dependency in Shipping binary.' } +} Write-Host "Verified compile/link graph and actual runtime objects: remote $Remote" From 5a88970cebbc47bc5e7d258525bea1ee63a31bb6 Mon Sep 17 00:00:00 2001 From: link1345 Date: Sun, 1 Nov 2026 11:28:10 +0900 Subject: [PATCH 4/5] =?UTF-8?q?Unity=20=E9=85=8D=E5=B8=83=E5=85=88?= =?UTF-8?q?=E3=81=AE=E6=9C=AA=E6=A4=9C=E8=A8=BC=20DLL=20=E3=82=92=E6=8B=92?= =?UTF-8?q?=E5=90=A6=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/remote-build-variants.yml | 8 ++++-- bindings/unity/Editor/GuaVariantBuildGuard.cs | 8 ++++++ docs/remote-build-variants-evidence.md | 26 +++++++++++++++++++ scripts/assert-unity-native-layout.ps1 | 25 ++++++++++++++++++ scripts/build-unity-package.ps1 | 12 +++++++-- scripts/package-unity-upm-ci.ps1 | 5 +++- scripts/test-unity-package-mixing.ps1 | 17 ++++++++++++ scripts/verify-unity-variant-package.ps1 | 1 + 8 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 scripts/assert-unity-native-layout.ps1 create mode 100644 scripts/test-unity-package-mixing.ps1 diff --git a/.github/workflows/remote-build-variants.yml b/.github/workflows/remote-build-variants.yml index 622b87b5..a3a43e7a 100644 --- a/.github/workflows/remote-build-variants.yml +++ b/.github/workflows/remote-build-variants.yml @@ -88,12 +88,16 @@ jobs: pattern: selected-*-${{ github.sha }} path: artifacts/mixing - shell: pwsh - run: scripts/test-remote-package-mixing.ps1 -ShippingPackage artifacts/mixing/selected-shipping-${{ github.sha }} -QAPackage artifacts/mixing/selected-qa-${{ github.sha }} -AIPlayerPackage artifacts/mixing/selected-ai-player-${{ github.sha }} + run: | + scripts/test-remote-package-mixing.ps1 -ShippingPackage artifacts/mixing/selected-shipping-${{ github.sha }} -QAPackage artifacts/mixing/selected-qa-${{ github.sha }} -AIPlayerPackage artifacts/mixing/selected-ai-player-${{ github.sha }} + scripts/test-unity-package-mixing.ps1 -ShippingNativeDirectory artifacts/mixing/selected-shipping-${{ github.sha }} -QARuntimeLibrary artifacts/mixing/selected-qa-${{ github.sha }}/gua_runtime.dll - uses: actions/upload-artifact@v4 if: always() with: name: remote-mixing-${{ github.sha }} - path: build/package-mixing-*/results.json + path: | + build/package-mixing-*/results.json + build/unity-mixing-*/result.json if-no-files-found: error web-variants: runs-on: ubuntu-latest diff --git a/bindings/unity/Editor/GuaVariantBuildGuard.cs b/bindings/unity/Editor/GuaVariantBuildGuard.cs index 42e5b4be..b70f42c8 100644 --- a/bindings/unity/Editor/GuaVariantBuildGuard.cs +++ b/bindings/unity/Editor/GuaVariantBuildGuard.cs @@ -44,6 +44,14 @@ public void OnPreprocessBuild(BuildReport report) if (web ? (name != "libgua-core.a" && name != "libgua_runtime.a") : (name != "gua.dll" && name != "gua_runtime.dll" && name != "libgua.so" && name != "libgua_runtime.so" && name != "libgua.dylib" && name != "libgua_runtime.dylib")) continue; string file = ResolveAsset(importer.assetPath); + string[] pair = web ? new[] {"libgua-core.a","libgua_runtime.a"} : + name.EndsWith(".dll",StringComparison.Ordinal) ? new[] {"gua.dll","gua_runtime.dll"} : + name.EndsWith(".so",StringComparison.Ordinal) ? new[] {"libgua.so","libgua_runtime.so"} : new[] {"libgua.dylib","libgua_runtime.dylib"}; + var allowed = pair.Concat(pair.Select(n=>n+".meta")).Concat(new[]{"gua-build-manifest.json","gua-build-manifest.json.meta"}); + if(web) allowed=allowed.Concat(new[]{"Managed.meta"}); + if(web && GuaUnityCompiledBuild.IsRemoteEnabled()) allowed=allowed.Concat(new[]{"GuaWebMcp.jslib","GuaWebMcp.jslib.meta"}); + foreach(var candidate in Directory.GetFiles(Path.GetDirectoryName(file)!)) + if(!allowed.Contains(Path.GetFileName(candidate),StringComparer.Ordinal)) throw new InvalidDataException("Unexpected Unity native package file: "+Path.GetFileName(candidate)); string manifestPath = Path.Combine(Path.GetDirectoryName(file)!, "gua-build-manifest.json"); using var manifest = JsonDocument.Parse(File.ReadAllText(manifestPath)); ValidateIdentity(manifest.RootElement.GetProperty("build"), expected); diff --git a/docs/remote-build-variants-evidence.md b/docs/remote-build-variants-evidence.md index b6c7710d..5e4749e8 100644 --- a/docs/remote-build-variants-evidence.md +++ b/docs/remote-build-variants-evidence.md @@ -79,3 +79,29 @@ socket DLL 依存不在も再確認した。graph 検査は保存済み証拠 pr Draft PR は https://github.com/gua-project/gua/pull/186 。初回 CI は `b4c8d74` の PR merge snapshot。 Web static identity の4構成、標準回帰の Windows/Linux/macOS native/managed lane は合格した。 新たな実 WebAssembly variant acceptance と portable Shipping/AI Player CI は追加後 HEAD で評価する。 + +## 最終監査 pass 2 と追加修正 + +固定 HEAD `9df9192` の最終独立監査で、前回4件の修正を確認し、UPM native 配布先に +改名 QA DLL を加えた場合の extra-file 検査漏れを1件報告された。 +全 native ファイルを必要な pair/manifest/Unity meta と照合する共通検査を追加し、 +UPM verifier と実 Editor guard の双方で拒否する。managed 依存も既存 Plugins の +wildcard コピーから、明示した binding と解決済み dependency closure のコピーへ変更した。 +既存 Windows 開発用 packager も native を2個だけ明示コピーし IL/配布先を検査する。 +`test-unity-package-mixing.ps1` の正常→改名 QA DLL 故障注入が期待した +`Unexpected Unity native package file: gua_runtime_old.dll` で拒否された。 +実 Unity Editor でも同じ混入を行い Player 生成前の guard が同じ理由で失敗した。 +実ログは Shipping fixture の `editor-renamed-qa-fault.log`。故障ファイルは除去済み。 +監査は通常タスクの上限2/2を消費しており、修正の検証は親が担当、追加監査は行わない。 + +最終修正 guard を再コンパイルし、同一 guard を4つの既存隔離 fixture へ入れて +Player を再ビルド・実行した。4構成とも `GUA_UNITY_VARIANT_PASS` と終了0を確認。 +`build/unity-variant-{shipping,ai-player,qa,development}-*/editor-final.log` と +`player-final.log`、`Player/result.json` に実証を保存した。 +native identity は各構築時点のものであり、全件を同一最終 HEAD の native build と扱わない。 +最終 HEAD 自体の新規ビルドは CI 側で判定する。 + +HEAD `9df9192` の PR CI では、実 WebAssembly(Node上)の4構成 profile/input/resetテスト、 +Linux/macOS の Shipping・AI Player 実 native と staging、Development OFF が合格。 +Windows full-profile GDE の4構成と Godot packaged consumer は実行中。 +これは WebGL browser/Unity WebGL Player の完了証明ではなく、各証拠の実行経路を区別する。 diff --git a/scripts/assert-unity-native-layout.ps1 b/scripts/assert-unity-native-layout.ps1 new file mode 100644 index 00000000..f031a824 --- /dev/null +++ b/scripts/assert-unity-native-layout.ps1 @@ -0,0 +1,25 @@ +param( + [Parameter(Mandatory)][string]$Directory, + [Parameter(Mandatory)][ValidateSet('Windows','Linux','macOS','WebGL')][string]$Platform, + [Parameter(Mandatory)][ValidateSet('on','off')][string]$Remote +) +$ErrorActionPreference='Stop' +$names = switch ($Platform) { + Windows { @('gua.dll','gua_runtime.dll') } + Linux { @('libgua.so','libgua_runtime.so') } + macOS { @('libgua.dylib','libgua_runtime.dylib') } + WebGL { @('libgua-core.a','libgua_runtime.a') } +} +$allowed=@('gua-build-manifest.json','gua-build-manifest.json.meta') + $names + @($names | ForEach-Object { "$_.meta" }) +if ($Platform -eq 'WebGL') { + $allowed += @('Managed.meta','Managed/Gua.Core.dll','Managed/Gua.Runtime.dll','Managed/Gua.Core.dll.meta','Managed/Gua.Runtime.dll.meta') + if ($Remote -eq 'on') { $allowed += @('GuaWebMcp.jslib','GuaWebMcp.jslib.meta') } +} +$nativeRoot=[IO.Path]::GetFullPath($Directory) +if ((Get-Item -LiteralPath $nativeRoot).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Unity native package cannot contain links.' } +foreach ($item in Get-ChildItem -LiteralPath $nativeRoot -Recurse -Force) { + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Unity native package cannot contain links.' } + if ($item.PSIsContainer) { continue } + $relative=[IO.Path]::GetRelativePath($nativeRoot,$item.FullName).Replace('\','/') + if ($allowed -cnotcontains $relative) { throw "Unexpected Unity native package file: $relative" } +} diff --git a/scripts/build-unity-package.ps1 b/scripts/build-unity-package.ps1 index 95cd1c71..4213dd8e 100644 --- a/scripts/build-unity-package.ps1 +++ b/scripts/build-unity-package.ps1 @@ -65,8 +65,13 @@ Copy-Item (Join-Path $root "bindings\unity\package.json") $artifact Copy-Item (Join-Path $root "bindings\unity\Documentation~\index.md") (Join-Path $artifact "Documentation~") Copy-Item (Join-Path $root "bindings\unity\Samples~") $artifact -Recurse Copy-Item (Join-Path $root "bindings\unity\Runtime\link.xml") (Join-Path $artifact "Runtime") -Copy-Item (Join-Path $plugins "Managed\*.dll") (Join-Path $artifact "Runtime\Plugins\Managed") -Copy-Item (Join-Path $plugins "Windows\x86_64\*.dll") (Join-Path $artifact "Runtime\Plugins\Windows\x86_64") +foreach ($assembly in 'Gua.Core.dll','Gua.Runtime.dll') { + Copy-Item -LiteralPath (Join-Path $plugins "Managed/$assembly") -Destination (Join-Path $artifact 'Runtime/Plugins/Managed') +} +& (Join-Path $PSScriptRoot 'copy-unity-managed-closure.ps1') -AssetsFile (Join-Path $root 'bindings/dotnet/src/Gua.Runtime/obj/project.assets.json') -TargetFramework netstandard2.1 -Destination (Join-Path $artifact 'Runtime/Plugins/Managed') +foreach ($library in 'gua.dll','gua_runtime.dll') { + Copy-Item -LiteralPath (Join-Path $plugins "Windows/x86_64/$library") -Destination (Join-Path $artifact 'Runtime/Plugins/Windows/x86_64') +} Copy-Item -LiteralPath (Join-Path $plugins "Windows/x86_64/gua-build-manifest.json") -Destination (Join-Path $artifact "Runtime/Plugins/Windows/x86_64") Copy-Item (Join-Path $root "scripts\unity-meta\gua.dll.meta") (Join-Path $artifact "Runtime\Plugins\Windows\x86_64") Copy-Item (Join-Path $root "scripts\unity-meta\gua_runtime.dll.meta") (Join-Path $artifact "Runtime\Plugins\Windows\x86_64") @@ -78,6 +83,9 @@ Copy-Item (Join-Path $root "scripts\unity-meta\Gua.Unity.dll.meta") (Join-Path $ Copy-Item (Join-Path $root "scripts\unity-meta\Gua.Unity.Bootstrap.dll.meta") (Join-Path $artifact "Runtime\Plugins\Managed") Copy-Item (Join-Path $root "scripts\unity-meta\Gua.Unity.TMP.dll.meta") (Join-Path $artifact "Runtime\Plugins\Managed") Copy-Item (Join-Path $root "scripts\unity-meta\Gua.Unity.Editor.dll.meta") (Join-Path $artifact "Editor") +& (Join-Path $PSScriptRoot 'assert-unity-native-layout.ps1') -Directory (Join-Path $artifact 'Runtime/Plugins/Windows/x86_64') -Platform Windows -Remote on +$compiled = (& dotnet (Join-Path $root 'tools/Gua.ArtifactProbe/bin/Release/net10.0/Gua.ArtifactProbe.dll') managed (Join-Path $artifact 'Runtime/Plugins/Managed/Gua.Unity.dll')) | ConvertFrom-Json +if ($LASTEXITCODE -or -not $compiled.RemoteEnabled -or $compiled.PlayerOnly -or -not $compiled.remoteCommandHandler -or -not $compiled.remoteStartReference -or -not $compiled.webInstallImport) { throw 'Development Unity package contains a different compiled managed variant.' } $tgzStaging = Join-Path $root "artifacts\unity\tgz-staging" if (Test-Path -LiteralPath $tgzStaging) { Remove-Item -LiteralPath $tgzStaging -Recurse -Force } New-Item -ItemType Directory -Path $tgzStaging -Force | Out-Null diff --git a/scripts/package-unity-upm-ci.ps1 b/scripts/package-unity-upm-ci.ps1 index 8efbb9fb..2d87d3c0 100644 --- a/scripts/package-unity-upm-ci.ps1 +++ b/scripts/package-unity-upm-ci.ps1 @@ -36,7 +36,10 @@ $package | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $artif Copy-Item (Join-Path $root "bindings/unity/Documentation~/index.md") (Join-Path $artifact "Documentation~") Copy-Item (Join-Path $root "bindings/unity/Samples~") $artifact -Recurse Copy-Item (Join-Path $root "bindings/unity/Runtime/link.xml") (Join-Path $artifact "Runtime") -Copy-Item (Join-Path $plugins "Managed/*.dll") (Join-Path $artifact "Runtime/Plugins/Managed") +foreach ($assembly in 'Gua.Core.dll','Gua.Runtime.dll') { + Copy-Item -LiteralPath (Join-Path $plugins "Managed/$assembly") -Destination (Join-Path $artifact 'Runtime/Plugins/Managed') +} +& (Join-Path $PSScriptRoot 'copy-unity-managed-closure.ps1') -AssetsFile (Join-Path $root 'bindings/dotnet/src/Gua.Runtime/obj/project.assets.json') -TargetFramework netstandard2.1 -Destination (Join-Path $artifact 'Runtime/Plugins/Managed') Copy-Item (Join-Path $root "scripts/unity-meta/Gua.Core.dll.meta") (Join-Path $artifact "Runtime/Plugins/Managed") Copy-Item (Join-Path $root "scripts/unity-meta/Gua.Runtime.dll.meta") (Join-Path $artifact "Runtime/Plugins/Managed") if (-not $DesktopOnly) { diff --git a/scripts/test-unity-package-mixing.ps1 b/scripts/test-unity-package-mixing.ps1 new file mode 100644 index 00000000..228dac98 --- /dev/null +++ b/scripts/test-unity-package-mixing.ps1 @@ -0,0 +1,17 @@ +param( + [Parameter(Mandatory)][string]$ShippingNativeDirectory, + [Parameter(Mandatory)][string]$QARuntimeLibrary +) +$ErrorActionPreference='Stop' +$root=Split-Path -Parent $PSScriptRoot +$fixture=Join-Path $root ('build/unity-mixing-'+[Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory $fixture | Out-Null +Copy-Item -LiteralPath (Join-Path $ShippingNativeDirectory 'gua.dll'),(Join-Path $ShippingNativeDirectory 'gua_runtime.dll'),(Join-Path $ShippingNativeDirectory 'gua-build-manifest.json') -Destination $fixture +$check=Join-Path $PSScriptRoot 'assert-unity-native-layout.ps1' +& $check -Directory $fixture -Platform Windows -Remote off +Copy-Item -LiteralPath $QARuntimeLibrary -Destination (Join-Path $fixture 'gua_runtime_old.dll') +$reason='' +try { & $check -Directory $fixture -Platform Windows -Remote off } catch { $reason=$_.Exception.Message } +if ($reason -cne 'Unexpected Unity native package file: gua_runtime_old.dll') { throw "Renamed QA DLL was not rejected by the expected UPM boundary: $reason" } +@{passed=$true;reason=$reason} | ConvertTo-Json | Set-Content (Join-Path $fixture 'result.json') -Encoding utf8NoBOM +Write-Host "Renamed QA DLL refused by Unity package boundary: $fixture" diff --git a/scripts/verify-unity-variant-package.ps1 b/scripts/verify-unity-variant-package.ps1 index db66378f..4deb6091 100644 --- a/scripts/verify-unity-variant-package.ps1 +++ b/scripts/verify-unity-variant-package.ps1 @@ -20,6 +20,7 @@ if (-not $DesktopOnly) { $layouts += @{path='WebGL';names=@('libgua-core.a','lib $source = $null; $build = $null foreach ($layout in $layouts) { $native = Join-Path $Directory "Runtime/Plugins/$($layout.path)" + & (Join-Path $PSScriptRoot 'assert-unity-native-layout.ps1') -Directory $native -Platform ($layout.path.Split('/')[0]) -Remote $Remote $manifest = Get-Content -LiteralPath (Join-Path $native 'gua-build-manifest.json') -Raw | ConvertFrom-Json $info = $manifest.build if ($manifest.schemaVersion -ne 1 -or $info.flavor -ne $Flavor -or $info.remoteEnabled -ne $enabled -or From 4a4debd735175df0e229c39aac33e9bf35c8db0b Mon Sep 17 00:00:00 2001 From: link1345 Date: Sun, 1 Nov 2026 11:41:34 +0900 Subject: [PATCH 5/5] =?UTF-8?q?UPM=20=E3=81=AE=E5=AE=9F=20native=20identit?= =?UTF-8?q?y=20=E3=81=A8=E6=94=B9=E5=A4=89=20hash=20=E3=82=92=E7=85=A7?= =?UTF-8?q?=E5=90=88=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/remote-build-variants-evidence.md | 14 +++++++++ scripts/test-unity-upm-mixing.ps1 | 39 ++++++++++++++++++++++++ scripts/verify-unity-variant-package.ps1 | 6 ++++ 3 files changed, 59 insertions(+) create mode 100644 scripts/test-unity-upm-mixing.ps1 diff --git a/docs/remote-build-variants-evidence.md b/docs/remote-build-variants-evidence.md index 5e4749e8..64251a66 100644 --- a/docs/remote-build-variants-evidence.md +++ b/docs/remote-build-variants-evidence.md @@ -105,3 +105,17 @@ HEAD `9df9192` の PR CI では、実 WebAssembly(Node上)の4構成 profile Linux/macOS の Shipping・AI Player 実 native と staging、Development OFF が合格。 Windows full-profile GDE の4構成と Godot packaged consumer は実行中。 これは WebGL browser/Unity WebGL Player の完了証明ではなく、各証拠の実行経路を区別する。 + +## UPM 構造の実ファイル照合 + +同じ CI run `41265352383`(HEAD `5a88970`)の実 native 配布物を取得し、実 Unity IL と +Windows/Linux/macOS arm64 の UPM 配置で verifier の正常系を確認した。 +macOS universal archive の実生成・実行を証明するものではない。 +`verify-unity-variant-package.ps1` は host と同じ native pair の build-info も再 probe し、 +manifest の宣言だけで identity を通さない。他 RID のバイナリは各 producer CI の実 probe と +manifest/hash を照合する。異なる architecture の DLL を同じ host で実行したとは扱わない。 +`test-unity-upm-mixing.ps1` はその実 verifier に、改名 QA DLL と hash を更新した +canonical QA runtime 置換を行い、それぞれ extra-file / compiled-identity の正しい理由で拒否した。 +実 fixture は `build/upm-native-proof-ce242cdb`、fault 結果は +`build/upm-mixing-d9410835c69249efb097d34a902ec9a7/results.json`。 +最終独立監査は2/2終了済みであり、この検証補強は親が実装・実証した。 diff --git a/scripts/test-unity-upm-mixing.ps1 b/scripts/test-unity-upm-mixing.ps1 new file mode 100644 index 00000000..b4156a0f --- /dev/null +++ b/scripts/test-unity-upm-mixing.ps1 @@ -0,0 +1,39 @@ +param( + [Parameter(Mandatory)][string]$PackageDirectory, + [Parameter(Mandatory)][ValidateSet('shipping','development','qa','ai-player')][string]$Flavor, + [Parameter(Mandatory)][ValidateSet('on','off')][string]$Remote, + [Parameter(Mandatory)][string]$QARuntimeLibrary, + [switch]$DesktopOnly +) +$ErrorActionPreference='Stop' +if (-not $IsWindows) { throw 'This fault uses an actual Windows QA DLL and requires a Windows host.' } +$root=Split-Path -Parent $PSScriptRoot +$verify=Join-Path $PSScriptRoot 'verify-unity-variant-package.ps1' +& $verify -Directory $PackageDirectory -Flavor $Flavor -Remote $Remote -DesktopOnly:$DesktopOnly +$evidence=Join-Path $root ('build/upm-mixing-'+[Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory $evidence | Out-Null +$results=@() +foreach ($fault in 'renamed-qa','rehashed-qa') { + $fixture=Join-Path $evidence $fault + Copy-Item -LiteralPath $PackageDirectory -Destination $fixture -Recurse + $native=Join-Path $fixture 'Runtime/Plugins/Windows/x86_64' + if ($fault -eq 'renamed-qa') { + Copy-Item -LiteralPath $QARuntimeLibrary -Destination (Join-Path $native 'gua_runtime_old.dll') + $expected='Unexpected Unity native package file: gua_runtime_old.dll' + } else { + Copy-Item -LiteralPath $QARuntimeLibrary -Destination (Join-Path $native 'gua_runtime.dll') -Force + $manifestPath=Join-Path $native 'gua-build-manifest.json' + $manifest=Get-Content $manifestPath -Raw | ConvertFrom-Json + $entry=@($manifest.files | Where-Object path -CEQ 'gua_runtime.dll') + if ($entry.Count -ne 1) { throw 'Expected one runtime hash in the verified fixture.' } + $entry[0].sha256=(Get-FileHash -LiteralPath (Join-Path $native 'gua_runtime.dll') -Algorithm SHA256).Hash.ToLowerInvariant() + $manifest | ConvertTo-Json -Depth 12 | Set-Content $manifestPath -Encoding utf8NoBOM + $expected='Unity native compiled identity mismatch: Windows/x86_64/gua_runtime.dll' + } + $reason='' + try { & $verify -Directory $fixture -Flavor $Flavor -Remote $Remote -DesktopOnly:$DesktopOnly } catch { $reason=$_.Exception.Message } + if ($reason -cne $expected) { throw "UPM fault $fault was not rejected at its required boundary: $reason" } + $results+=@{fault=$fault;passed=$true;reason=$reason} +} +$results | ConvertTo-Json | Set-Content (Join-Path $evidence 'results.json') -Encoding utf8NoBOM +Write-Host "Actual UPM verifier refused renamed and rehashed QA DLLs: $evidence" diff --git a/scripts/verify-unity-variant-package.ps1 b/scripts/verify-unity-variant-package.ps1 index 4deb6091..0946dba9 100644 --- a/scripts/verify-unity-variant-package.ps1 +++ b/scripts/verify-unity-variant-package.ps1 @@ -31,6 +31,12 @@ foreach ($layout in $layouts) { foreach ($name in $layout.names) { $entry=@($manifest.files | Where-Object path -CEQ $name) if ($entry.Count -ne 1 -or (Get-FileHash -LiteralPath (Join-Path $native $name) -Algorithm SHA256).Hash.ToLowerInvariant() -ne $entry[0].sha256) { throw "Unity native hash mismatch: $($layout.path)/$name" } + $hostLayout=if ($IsWindows) {'Windows/x86_64'} elseif ($IsMacOS) {'macOS'} else {'Linux/x86_64'} + if ($layout.path -eq $hostLayout) { + $symbol=if ($name.Contains('runtime')) {'gua_runtime_copy_build_info_json'} else {'gua_copy_build_info_json'} + $actualInfo=(& dotnet $probe native (Join-Path $native $name) $symbol) | ConvertFrom-Json + if ($LASTEXITCODE -or ($actualInfo | ConvertTo-Json -Depth 12 -Compress) -ne ($info | ConvertTo-Json -Depth 12 -Compress)) { throw "Unity native compiled identity mismatch: $($layout.path)/$name" } + } } } $js = @(Get-ChildItem -LiteralPath $Directory -Recurse -File -Filter '*.jslib')