Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 86 additions & 6 deletions .github/workflows/package-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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: ''
Expand All @@ -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

Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,36 @@
</ItemGroup>

<ItemGroup>
<Content Include="runtimes\**" PackagePath="runtimes"
<!-- The wasm archive is packed separately, under the name the DllImports use. -->
<Content Include="runtimes\**"
Exclude="runtimes\browser-wasm\native\libJoltC.a"
PackagePath="runtimes"
Visible="true" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

<!--
browser-wasm links statically, and the module name a DllImport resolves against comes from the
file name. CMake produces libJoltC.a while every DllImport in Generated says "JoltC", so the
package renames it. Until this was added the package shipped the archive at a path nothing
probes, under a name nothing would have matched.

Deliberately not conditioned on the file existing, unlike the same block in
Evergine.Bindings.CesiumNative. There the ten libraries are fetched at CD time and absent from
a fresh clone, so a condition is the only way to pack at all. Here they are committed to the
repository, so a missing archive means the fetch that overwrites them went wrong, and packing
should fail and say so rather than quietly ship a package with no wasm in it.
-->
<ItemGroup>
<Content Include="runtimes\browser-wasm\native\libJoltC.a"
PackagePath="runtimes\browser-wasm\native\JoltC.a"
Visible="true" />
</ItemGroup>

<ItemGroup>
<Content Include="buildTransitive\Evergine.Bindings.JoltPhysics.targets"
PackagePath="buildTransitive" Visible="true" />
</ItemGroup>

<ItemGroup>
<None Include="..\icon.png" Pack="true" PackagePath="\" Visible="false" />
<None Include="..\README.md" Pack="true" PackagePath="\" Visible="false" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">

<!--
browser-wasm has no dynamic loading, so runtimes/browser-wasm/native is not probed at run
time the way the desktop identifiers are: the archive has to be linked into the application
when it publishes. That is what this does, and the package shipped for a long time without
it, carrying a 3.6 MB archive that no consumer could ever link.

ScanForPInvokes is what makes the DllImports resolve. It has the build read the managed
assembly's P/Invokes and emit the table the runtime looks them up in; the reference alone
puts the code in the module and leaves every call unresolved.

The archive is packed as JoltC.a rather than the libJoltC.a that CMake produces, because the
module name comes from the file name and every DllImport in Generated says "JoltC".
Evergine.Bindings.CesiumNative ships CesiumNativeC.a and Evergine.Bindings.Imgui ships
cimgui.a on the same arrangement; neither carries a lib prefix.
-->
<ItemGroup Condition="'$(RuntimeIdentifier)' == 'browser-wasm'">
<NativeFileReference Include="$(MSBuildThisFileDirectory)../runtimes/browser-wasm/native/JoltC.a"
ScanForPInvokes="true" />
</ItemGroup>

</Project>
13 changes: 13 additions & 0 deletions WasmSmokeTest/NuGet.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<!--
Restores the package under test from a local folder rather than nuget.org, which is the whole
point: this runs before anything is published, so the version it needs does not exist anywhere
else yet.
-->
<packageSources>
<clear />
<add key="local" value="local-packages" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>
152 changes: 152 additions & 0 deletions WasmSmokeTest/Program.cs
Original file line number Diff line number Diff line change
@@ -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/<rid>/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;
}
}
36 changes: 36 additions & 0 deletions WasmSmokeTest/WasmSmokeTest.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RuntimeIdentifier>browser-wasm</RuntimeIdentifier>
<OutputType>Exe</OutputType>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>disable</Nullable>
<WasmMainJSPath>main.mjs</WasmMainJSPath>

<!--
The archive is linked at publish time, so the native build has to run. Without this the
package's NativeFileReference is carried and never used, and every DllImport fails at run
time with the module already loaded, which reads as a runtime bug rather than the packaging
one it is.
-->
<WasmBuildNative>true</WasmBuildNative>

<!-- Not a library consumer's concern, but this project exists to be run in CI. -->
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>

<!--
A PackageReference on purpose, not a ProjectReference. The thing under test is the package: its
targets file, the name the archive is packed under, and whether the two together make the
DllImports resolve. A project reference would bypass all three and pass regardless, which is
exactly how the package shipped an unlinkable wasm archive for months.

The version is supplied by the workflow, which restores from the folder holding the .nupkg the
CD produced.
-->
<ItemGroup>
<PackageReference Include="Evergine.Bindings.JoltPhysics" Version="$(JoltPhysicsVersion)" />
</ItemGroup>

</Project>
8 changes: 8 additions & 0 deletions WasmSmokeTest/main.mjs
Original file line number Diff line number Diff line change
@@ -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);
Loading