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);