diff --git a/.github/workflows/package-smoke.yml b/.github/workflows/package-smoke.yml index a02fd7d..15d4e0c 100644 --- a/.github/workflows/package-smoke.yml +++ b/.github/workflows/package-smoke.yml @@ -4,9 +4,17 @@ # produced, drops a sphere onto a floor through the native library, and fails the job if the # sphere does not fall, does not settle, or falls through. # -# Five of the ten identifiers, and the gap is honest rather than an oversight: android-arm, -# android-arm64, ios-arm64, iossimulator-arm64 and browser-wasm ship in the package and no -# GitHub runner can execute a console application on them. They are built and never run. +# Six of the ten identifiers, and the gap is honest rather than an oversight: android-arm, +# android-arm64, ios-arm64 and iossimulator-arm64 ship in the package and no GitHub runner can +# execute a console application on them. They are built and never run. +# +# browser-wasm is the sixth and it needs its own job rather than a row in the matrix, because +# nothing about it resembles the others: the archive is linked into the application when it +# publishes instead of being loaded out of runtimes/ at run time, so what makes it work is the +# package's buildTransitive targets file and the name the archive is packed under. For months +# there was no targets file and the archive was packed as libJoltC.a against DllImport("JoltC"), +# and the package shipped 3.6 MB of wasm that no consumer could have linked. Nothing failed, +# because nothing tried. This is the leg that tries. name: Package smoke test on: @@ -22,7 +30,9 @@ on: type: string default: '' rids: - description: 'Comma-separated runtime identifiers to run. Empty means all five.' + description: >- + Comma-separated desktop runtime identifiers to run. Empty means all five. The wasm leg + is a separate job and always runs. required: false type: string default: '' @@ -37,8 +47,9 @@ on: type: string rids: description: >- - Comma-separated runtime identifiers to run, e.g. win-arm64. Leave empty for all five. - Retrying one identifier should not drag the four that already passed along with it. + Comma-separated desktop runtime identifiers to run, e.g. win-arm64. Leave empty for all + five. Retrying one identifier should not drag the four that already passed along with + it. This does not reach the wasm leg, which is a separate job and always runs. required: false type: string @@ -161,3 +172,72 @@ jobs: set -euo pipefail dotnet run -c Release -r ${{ matrix.rid }} --self-contained false \ -p:JoltPhysicsVersion="$JOLT_VERSION" + + # Its own job rather than a row in the matrix above, because almost nothing is shared: there is + # no runtime identifier to select, the wasm workload has to be installed, publishing is not + # optional, and the thing that runs the result is node rather than the host. + # + # The fetch and the version parse are repeated from the desktop job instead of being hoisted + # into `prepare`. Hoisting would mean prepare downloading the artifact too, which trades twelve + # duplicated lines for a coupling between a job that decides a matrix and the artifact under + # test. The duplication is the cheaper of the two. + wasm: + name: browser-wasm + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.x' + + # Not present on the runner by default, and without it WasmBuildNative has no Emscripten to + # link with. + - name: Install the wasm workload + run: dotnet workload install wasm-tools + + - name: Fetch the package from this run + if: inputs.cd-run-id == '' + uses: actions/download-artifact@v4 + with: + name: nuget-packages + path: WasmSmokeTest/local-packages + + - name: Fetch the package from an earlier run + if: inputs.cd-run-id != '' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir -p WasmSmokeTest/local-packages + gh run download "${{ inputs.cd-run-id }}" --repo "$GITHUB_REPOSITORY" \ + -n nuget-packages -D WasmSmokeTest/local-packages + + - name: Work out which version is under test + shell: bash + run: | + set -euo pipefail + ls -la WasmSmokeTest/local-packages/ + + pkg=$(ls WasmSmokeTest/local-packages/*.nupkg | head -1) + version=$(basename "$pkg" .nupkg | sed 's/^Evergine\.Bindings\.JoltPhysics\.//') + + if [ -z "$version" ] || [ "$version" = "$(basename "$pkg" .nupkg)" ]; then + echo "::error::could not read a version out of '$(basename "$pkg")'" + exit 1 + fi + + echo "JOLT_VERSION=$version" >> "$GITHUB_ENV" + echo "testing $version on browser-wasm" + + # Publish then node, not `dotnet run`: there is no host that runs a browser-wasm application, + # and the link that this whole leg exists to check only happens on publish. + - name: Run the package on browser-wasm + shell: bash + working-directory: WasmSmokeTest + run: | + set -euo pipefail + dotnet publish -c Release -p:JoltPhysicsVersion="$JOLT_VERSION" + node bin/Release/net10.0/browser-wasm/AppBundle/main.mjs diff --git a/Evergine.Bindings.JoltPhysics/Evergine.Bindings.JoltPhysics.csproj b/Evergine.Bindings.JoltPhysics/Evergine.Bindings.JoltPhysics.csproj index a39e08e..84dd50a 100644 --- a/Evergine.Bindings.JoltPhysics/Evergine.Bindings.JoltPhysics.csproj +++ b/Evergine.Bindings.JoltPhysics/Evergine.Bindings.JoltPhysics.csproj @@ -17,10 +17,36 @@ - + + + + + + + + + + diff --git a/Evergine.Bindings.JoltPhysics/buildTransitive/Evergine.Bindings.JoltPhysics.targets b/Evergine.Bindings.JoltPhysics/buildTransitive/Evergine.Bindings.JoltPhysics.targets new file mode 100644 index 0000000..7bc9ad1 --- /dev/null +++ b/Evergine.Bindings.JoltPhysics/buildTransitive/Evergine.Bindings.JoltPhysics.targets @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/WasmSmokeTest/NuGet.config b/WasmSmokeTest/NuGet.config new file mode 100644 index 0000000..037b066 --- /dev/null +++ b/WasmSmokeTest/NuGet.config @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/WasmSmokeTest/Program.cs b/WasmSmokeTest/Program.cs new file mode 100644 index 0000000..9689308 --- /dev/null +++ b/WasmSmokeTest/Program.cs @@ -0,0 +1,152 @@ +// Proves that the published package works from a .NET wasm application. +// +// The scenario is the one DesktopSmokeTest runs, and it is here for a different reason. On the +// desktop identifiers the archive is loaded at run time out of runtimes//native, and this +// package got that right from the start. browser-wasm is linked at publish time instead, through +// a NativeFileReference the package's buildTransitive targets file adds -- and for months there +// was no targets file and the archive was packed under a name no DllImport could match, so the +// wasm library shipped in every release and no consumer could have linked it. Nothing failed, +// because nothing tried. +// +// This is the only test that tries. It goes through PackageReference on purpose, so the targets +// file and the packed file name are part of what is under test. +// +// Two things differ from the desktop version, and both are about the platform rather than taste: +// +// numThreads = 0. There is no pthread here, so the pool is created with no workers and +// JoltPhysics runs jobs on the thread that waits on the barrier. +// +// Looser bounds on the result. The same drop settles in 63 steps at y=0.486 here against 48 +// steps at y=0.480 on the desktop legs, so asserting the desktop numbers would fail for a +// reason that has nothing to do with the package being correct. + +using System; +using System.Runtime.InteropServices; +using Evergine.Bindings.JoltPhysics; + +internal static unsafe class Program +{ + private const ushort LayerNonMoving = 0; + private const ushort LayerMoving = 1; + private const uint NumObjectLayers = 2; + private const uint NumBroadPhaseLayers = 2; + + private const float DeltaTime = 1.0f / 60.0f; + private const int MaxSteps = 600; // ten seconds of simulation + + private static int _failures; + + private static void Check(bool condition, string what) + { + Console.WriteLine((condition ? " ok " : " FAIL ") + what); + if (!condition) + { + _failures++; + } + } + + private static int Main() + { + Console.WriteLine($"JoltC on {RuntimeInformation.RuntimeIdentifier}"); + + // Reaching native code at all. On wasm this is where a missing targets file, or an archive + // packed under the wrong name, stops being invisible. + JoltPhysics.RegisterDefaultAllocator(); + JoltPhysics.Init(); + JoltPhysics.CreateFactory(); + JoltPhysics.RegisterTypes(); + + IntPtr tempAllocator = JoltPhysics.TempAllocator_Create(4 * 1024 * 1024); + IntPtr jobSystem = JoltPhysics.JobSystemThreadPool_Create(1024, 8, 0); + Check(tempAllocator != IntPtr.Zero && jobSystem != IntPtr.Zero, + "the allocator and job system were created, so the archive linked and the DllImports resolved"); + + IntPtr broadPhase = JoltPhysics.BroadPhaseLayerInterfaceTable_Create(NumObjectLayers, NumBroadPhaseLayers); + JoltPhysics.BroadPhaseLayerInterfaceTable_MapObjectToBroadPhaseLayer(broadPhase, LayerNonMoving, 0); + JoltPhysics.BroadPhaseLayerInterfaceTable_MapObjectToBroadPhaseLayer(broadPhase, LayerMoving, 1); + + IntPtr objectLayerPairFilter = JoltPhysics.ObjectLayerPairFilterTable_Create(NumObjectLayers); + JoltPhysics.ObjectLayerPairFilterTable_EnableCollision(objectLayerPairFilter, LayerNonMoving, LayerMoving); + JoltPhysics.ObjectLayerPairFilterTable_EnableCollision(objectLayerPairFilter, LayerMoving, LayerMoving); + + IntPtr objectVsBroadPhaseFilter = JoltPhysics.ObjectVsBroadPhaseLayerFilterTable_Create( + broadPhase, NumBroadPhaseLayers, objectLayerPairFilter, NumObjectLayers); + + IntPtr physicsSystem = JoltPhysics.PhysicsSystem_Create(); + JoltPhysics.PhysicsSystem_Init( + physicsSystem, 1024, 0, 1024, 1024, + broadPhase, objectVsBroadPhaseFilter, objectLayerPairFilter); + Check(physicsSystem != IntPtr.Zero, "the physics system was created"); + + IntPtr bodyInterface = JoltPhysics.PhysicsSystem_GetBodyInterface(physicsSystem); + + // A floor whose top surface sits at y = 0. + IntPtr floorShape = JoltPhysics.BoxShape_Create(new Vec3 { X = 100.0f, Y = 1.0f, Z = 100.0f }, 0.05f); + BodyCreationSettings floorSettings = default; + JoltPhysics.BodyCreationSettings_SetDefault(&floorSettings); + floorSettings.Position = new RVec3 { X = 0.0f, Y = -1.0f, Z = 0.0f }; + floorSettings.Rotation = new Quat { X = 0f, Y = 0f, Z = 0f, W = 1f }; + floorSettings.MotionType = MotionType.Static; + floorSettings.ObjectLayer = LayerNonMoving; + floorSettings.Shape = floorShape; + uint floorBodyId = JoltPhysics.BodyInterface_CreateBody(bodyInterface, &floorSettings); + JoltPhysics.BodyInterface_AddBody(bodyInterface, floorBodyId, Activation.DontActivate); + + // A sphere of radius 0.5 dropped from y = 2. + IntPtr sphereShape = JoltPhysics.SphereShape_Create(0.5f); + BodyCreationSettings sphereSettings = default; + JoltPhysics.BodyCreationSettings_SetDefault(&sphereSettings); + sphereSettings.Position = new RVec3 { X = 0.0f, Y = 2.0f, Z = 0.0f }; + sphereSettings.Rotation = new Quat { X = 0f, Y = 0f, Z = 0f, W = 1f }; + sphereSettings.MotionType = MotionType.Dynamic; + sphereSettings.ObjectLayer = LayerMoving; + sphereSettings.Shape = sphereShape; + uint sphereBodyId = JoltPhysics.BodyInterface_CreateBody(bodyInterface, &sphereSettings); + JoltPhysics.BodyInterface_AddBody(bodyInterface, sphereBodyId, Activation.Activate); + + JoltPhysics.PhysicsSystem_OptimizeBroadPhase(physicsSystem); + + double startY = JoltPhysics.BodyInterface_GetCenterOfMassPosition(bodyInterface, sphereBodyId).Y; + + int steps = 0; + while (JoltPhysics.BodyInterface_IsActive(bodyInterface, sphereBodyId) && steps < MaxSteps) + { + JoltPhysics.PhysicsSystem_Update(physicsSystem, DeltaTime, 1, tempAllocator, jobSystem); + steps++; + } + + double endY = JoltPhysics.BodyInterface_GetCenterOfMassPosition(bodyInterface, sphereBodyId).Y; + Console.WriteLine($" sphere went from y={startY:F3} to y={endY:F3} in {steps} steps"); + + // Simulation ran rather than merely being set up. Without this the test would pass against + // a library whose Update does nothing at all. + Check(steps > 0 && steps < MaxSteps, + $"the sphere came to rest in {steps} steps, so the simulation both ran and settled"); + Check(endY < startY, "the sphere fell, so gravity was integrated"); + + // Resting on the floor rather than through it. A sphere of radius 0.5 on a surface at + // y = 0 settles with its centre near y = 0.5, and anything below zero means collision did + // not happen -- which a build with the wrong flags can produce while still running. + Check(endY > 0.2 && endY < 0.8, + $"it settled on the floor rather than falling through it (y={endY:F3})"); + + JoltPhysics.BodyInterface_RemoveBody(bodyInterface, sphereBodyId); + JoltPhysics.BodyInterface_DestroyBody(bodyInterface, sphereBodyId); + JoltPhysics.BodyInterface_RemoveBody(bodyInterface, floorBodyId); + JoltPhysics.BodyInterface_DestroyBody(bodyInterface, floorBodyId); + JoltPhysics.Shape_Destroy(sphereShape); + JoltPhysics.Shape_Destroy(floorShape); + JoltPhysics.PhysicsSystem_Destroy(physicsSystem); + JoltPhysics.ObjectLayerPairFilter_Destroy(objectLayerPairFilter); + JoltPhysics.ObjectVsBroadPhaseLayerFilter_Destroy(objectVsBroadPhaseFilter); + JoltPhysics.BroadPhaseLayerInterface_Destroy(broadPhase); + JoltPhysics.JobSystem_Destroy(jobSystem); + JoltPhysics.TempAllocator_Destroy(tempAllocator); + JoltPhysics.UnregisterTypes(); + JoltPhysics.DestroyFactory(); + JoltPhysics.Shutdown(); + + Console.WriteLine(_failures == 0 ? "PASS" : $"FAIL ({_failures})"); + return _failures == 0 ? 0 : 1; + } +} diff --git a/WasmSmokeTest/WasmSmokeTest.csproj b/WasmSmokeTest/WasmSmokeTest.csproj new file mode 100644 index 0000000..1d1f2af --- /dev/null +++ b/WasmSmokeTest/WasmSmokeTest.csproj @@ -0,0 +1,36 @@ + + + + net10.0 + browser-wasm + Exe + true + disable + main.mjs + + + true + + + true + + + + + + + + diff --git a/WasmSmokeTest/main.mjs b/WasmSmokeTest/main.mjs new file mode 100644 index 0000000..d33e362 --- /dev/null +++ b/WasmSmokeTest/main.mjs @@ -0,0 +1,8 @@ +import { dotnet } from './_framework/dotnet.js' + +const { runMain } = await dotnet.create(); +const exitCode = await runMain(); + +// Hand the managed exit code to the shell, so a failed assertion fails the job rather than +// printing FAIL into a green run. +process.exit(exitCode);