diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cfbfe5..f1abaf8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,12 +38,25 @@ jobs: - { os: ubuntu-24.04-arm, rid: linux-arm64 } - { os: macos-14, rid: osx-x64 } - { os: macos-14, rid: osx-arm64 } + # Android cross-compiles from the Linux image, which already carries + # an NDK; build-native.ps1 finds it through ANDROID_NDK_LATEST_HOME. + # Only the 64-bit ABIs are built: Google Play has required 64-bit for + # years, and armeabi-v7a would ship a slower scalar build, because + # upstream disables NEON on armv7 where it has no divide or sqrt. + - { os: ubuntu-latest, rid: android-arm64 } + - { os: ubuntu-latest, rid: android-x64 } steps: - uses: actions/checkout@v4 with: submodules: recursive + # The NDK toolchain only drives single-configuration generators, so the + # build needs Ninja. The Linux image has CMake but not Ninja. + - name: Install Ninja + if: startsWith(matrix.rid, 'android-') + run: sudo apt-get update && sudo apt-get install -y ninja-build + - name: Build Box3D shell: pwsh run: ./tools/build-native.ps1 -Rid ${{ matrix.rid }} -Configuration Release @@ -55,6 +68,38 @@ jobs: if-no-files-found: error retention-days: 7 + # iOS is built in one job rather than as three more rows above, because its + # three slices are not three independent outputs: they have to be merged into + # a single xcframework, and only a job holding all of them can do that. The + # merge is Apple tooling, so it cannot be deferred to the pack job on Linux + # either. + apple: + name: native (ios) + runs-on: macos-14 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Build every iOS slice + shell: pwsh + run: | + foreach ($rid in 'ios-arm64', 'iossimulator-arm64', 'iossimulator-x64') { + ./tools/build-native.ps1 -Rid $rid -Configuration Release + } + + - name: Assemble the xcframework + shell: pwsh + run: ./tools/create-xcframework.ps1 + + - uses: actions/upload-artifact@v4 + with: + name: apple-xcframework + path: artifacts/apple/box3d.xcframework/ + if-no-files-found: error + retention-days: 7 + # Runs the managed test suite against the real native library. The layout and # math tests would pass without one; the interop tests are the ones that # actually prove the binding, so this job fails if any test is skipped. @@ -251,19 +296,36 @@ jobs: # Produces the packages that a release would publish, with every platform's # native library inside, and keeps them as an artifact for inspection. + # On macOS, and not by preference: this is the only job that builds the + # packages' iOS assembly, and the iOS workload has no Linux host pack at all. + # "dotnet workload install ios" fails on ubuntu rather than installing + # something that cannot link, so the packaging job goes where the toolchain + # exists. Everything else it does is platform-independent. pack: name: pack - needs: [native, test, lint] - runs-on: ubuntu-latest - timeout-minutes: 15 + needs: [native, apple, test, lint] + runs-on: macos-14 + timeout-minutes: 25 steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + # .NET 10 is here for the iOS target framework alone. The packages + # themselves still target net8.0 for every other platform, but the iOS + # assembly cannot be built by the .NET 8 SDK: its mobile workloads are out + # of support and the SDK refuses net8.0-ios outright. - uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: | + 8.0.x + 10.0.x + + # Only the iOS workload, and only to compile the managed assembly. Linking + # an actual application needs Xcode and happens in the consumer job; this + # produces the library half. + - name: Install the iOS workload + run: dotnet workload install ios --skip-sign-check - name: Collect every native runtime uses: actions/download-artifact@v4 @@ -271,6 +333,12 @@ jobs: pattern: native-* path: ./artifacts/native + - name: Collect the iOS framework + uses: actions/download-artifact@v4 + with: + name: apple-xcframework + path: ./artifacts/apple/box3d.xcframework + # download-artifact places each artifact in its own folder named after # the artifact, so native-win-x64/box3d.dll becomes # runtimes/win-x64/native/box3d.dll. @@ -295,15 +363,47 @@ jobs: - name: Verify every runtime is present shell: pwsh run: | - $required = @('win-x64', 'win-arm64', 'linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64') + $required = @( + 'win-x64', 'win-arm64' + 'linux-x64', 'linux-arm64' + 'osx-x64', 'osx-arm64' + 'android-arm64', 'android-x64' + ) $missing = $required | Where-Object { -not (Get-ChildItem "runtimes/$_/native" -File -ErrorAction SilentlyContinue) } if ($missing) { throw "No native library staged for: $($missing -join ', ')" } Write-Host "All $($required.Count) runtimes present." + # iOS is checked separately because it is not a runtime asset. The + # framework has to hold both variants: a device-only one links fine + # and then fails for everyone running on the simulator, which is most + # people most of the time. + $framework = './artifacts/apple/box3d.xcframework' + if (-not (Test-Path "$framework/Info.plist")) { + throw "No iOS xcframework was staged at $framework." + } + + $variants = Get-ChildItem $framework -Directory | ForEach-Object { $_.Name } + Write-Host "xcframework variants: $($variants -join ', ')" + + if (-not ($variants | Where-Object { $_ -notlike '*simulator*' })) { + throw 'The xcframework has no device variant.' + } + if (-not ($variants | Where-Object { $_ -like '*simulator*' })) { + throw 'The xcframework has no simulator variant.' + } + + # Box3DTargetApple is what adds the iOS target framework to the packable + # projects. It is off by default so that building this repository needs + # neither the .NET 10 SDK nor a workload that does not exist for Linux; + # this is the job that asks for it, and the only one that has to. - name: Pack - run: dotnet pack --configuration Release --output ./artifacts/packages + run: > + dotnet pack + --configuration Release + --output ./artifacts/packages + -p:Box3DTargetApple=true # Inspect the package rather than trusting that pack did the right thing. # A PackagePath that resolves to a directory rather than a file, for @@ -320,6 +420,8 @@ jobs: 'runtimes/linux-arm64/native/libbox3d.so' 'runtimes/osx-x64/native/libbox3d.dylib' 'runtimes/osx-arm64/native/libbox3d.dylib' + 'runtimes/android-arm64/native/libbox3d.so' + 'runtimes/android-x64/native/libbox3d.so' ) Add-Type -AssemblyName System.IO.Compression.FileSystem @@ -356,6 +458,33 @@ jobs: $failed = $true } } + + # iOS travels as build files rather than runtime assets, + # so none of the checks above would notice it missing - + # and a package that restores on iOS and then cannot link + # is exactly the failure this whole job exists to catch. + if (-not ($entries | Where-Object { $_ -like 'buildTransitive/*/Box3D.NET.Native.targets' })) { + Write-Host "::error::$($package.Name) carries no iOS build file" + $failed = $true + } + + foreach ($variant in 'ios-arm64', 'simulator') { + if (-not ($entries | Where-Object { $_ -like "buildTransitive/*/box3d.xcframework/*$variant*/libbox3d.a" })) { + Write-Host "::error::$($package.Name) is missing the $variant archive of box3d.xcframework" + $failed = $true + } + } + + # The static archives belong to the framework and nowhere + # else. One under runtimes/ is dead weight: nothing loads + # an archive at run time, so it would add megabytes while + # serving no platform at all. + foreach ($entry in $entries) { + if ($entry -like 'runtimes/*.a' -or $entry -like 'runtimes/*/*.a') { + Write-Host "::error::$($package.Name) has a static archive under runtimes/: $entry" + $failed = $true + } + } } foreach ($expected in 'README.md', 'LICENSE', 'THIRD-PARTY-NOTICES.txt', 'icon.png') { @@ -476,3 +605,66 @@ jobs: if: matrix.aot shell: pwsh run: ./tools/verify-package.ps1 -Rid ${{ matrix.rid }} -Mode Aot + + # The same idea as the job above, for the two platforms that have no + # executable a runner can start. + # + # Neither of these runs physics; they build a real application against the + # packed .nupkg and then open what it produced. That covers the part that is + # specific to mobile and invisible everywhere else: whether the native library + # is inside the apk, and whether the static archive survived the iOS link. + # What it does not cover is execution on a device, and the platform table says + # so rather than implying otherwise. + consumer-mobile: + name: package consumer (${{ matrix.platform }}) + needs: pack + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-latest, platform: Android, workload: android } + # A newer image than the jobs that only compile Box3D itself. Building + # the native library needs nothing but CMake and clang, which Xcode + # 15.4 on macos-14 provides; linking an iOS application is the .NET + # iOS workload's own job, and it refuses to run against an Xcode older + # than the one it was built for - 26.5 wants Xcode 26.6, and macos-14 + # tops out at 15.4. + - { os: macos-26, platform: iOS, workload: ios } + + steps: + - uses: actions/checkout@v4 + + # Reported, not chosen. macos-26 already defaults to Xcode 26.6, which is + # what this workload requires, and pointing xcode-select at the newest + # /Applications/Xcode_*.app instead broke the build: those side-by-side + # installs are not all complete, and the one picked had no macOS SDK, so + # actool could not run at all. + - name: Report the Xcode in use + if: matrix.platform == 'iOS' + run: xcodebuild -version + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + # The Android SDK tooling pins the JDK it accepts and rejects anything + # newer outright (XA0030), so the runner's default cannot be relied on. + - uses: actions/setup-java@v4 + if: matrix.platform == 'Android' + with: + distribution: temurin + java-version: '21' + + - name: Install the ${{ matrix.workload }} workload + run: dotnet workload install ${{ matrix.workload }} --skip-sign-check + + - uses: actions/download-artifact@v4 + with: + name: packages + path: ./artifacts/packages + + - name: Consume the package + shell: pwsh + run: ./tools/verify-package-mobile.ps1 -Platform ${{ matrix.platform }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 9ccf6dc..75fb129 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -47,8 +47,13 @@ jobs: - name: Restore tools run: dotnet tool restore + # net8.0 explicitly, matching the TargetFramework docfx.json asks for. + # Without it this builds every framework the project declares, which now + # includes an iOS one, and would need the .NET 10 SDK and the iOS workload + # on a runner that has no use for either: the public API is the same on + # both sides, and the reference is generated from the net8.0 assembly. - name: Build - run: dotnet build src/Box3D.NET/Box3D.NET.csproj --configuration Release + run: dotnet build src/Box3D.NET/Box3D.NET.csproj --configuration Release --framework net8.0 - name: Generate the icon shell: pwsh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d6039b7..a8dba15 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,12 +57,18 @@ jobs: - { os: ubuntu-24.04-arm, rid: linux-arm64 } - { os: macos-14, rid: osx-x64 } - { os: macos-14, rid: osx-arm64 } + - { os: ubuntu-latest, rid: android-arm64 } + - { os: ubuntu-latest, rid: android-x64 } steps: - uses: actions/checkout@v4 with: submodules: recursive + - name: Install Ninja + if: startsWith(matrix.rid, 'android-') + run: sudo apt-get update && sudo apt-get install -y ninja-build + - name: Build Box3D shell: pwsh run: ./tools/build-native.ps1 -Rid ${{ matrix.rid }} -Configuration Release @@ -73,20 +79,58 @@ jobs: path: runtimes/${{ matrix.rid }}/native/ if-no-files-found: error + # One job for all three iOS slices, because they have to be merged into a + # single xcframework and only Apple's tooling can do the merging. See the + # equivalent job in ci.yml. + apple: + name: native (ios) + runs-on: macos-14 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Build every iOS slice + shell: pwsh + run: | + foreach ($rid in 'ios-arm64', 'iossimulator-arm64', 'iossimulator-x64') { + ./tools/build-native.ps1 -Rid $rid -Configuration Release + } + + - name: Assemble the xcframework + shell: pwsh + run: ./tools/create-xcframework.ps1 + + - uses: actions/upload-artifact@v4 + with: + name: apple-xcframework + path: artifacts/apple/box3d.xcframework/ + if-no-files-found: error + + # On macOS because it builds the packages' iOS assembly, and the iOS workload + # has no Linux host pack. See the pack job in ci.yml. publish: name: publish - needs: native - runs-on: ubuntu-latest - timeout-minutes: 20 + needs: [native, apple] + runs-on: macos-14 + timeout-minutes: 30 steps: - uses: actions/checkout@v4 with: fetch-depth: 0 submodules: recursive + # .NET 10 is required to build the iOS assembly; every other platform is + # still net8.0. See the same step in ci.yml. - uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: | + 8.0.x + 10.0.x + + - name: Install the iOS workload + run: dotnet workload install ios --skip-sign-check - name: Resolve the version id: version @@ -109,6 +153,12 @@ jobs: pattern: native-* path: ./artifacts/native + - name: Collect the iOS framework + uses: actions/download-artifact@v4 + with: + name: apple-xcframework + path: ./artifacts/apple/box3d.xcframework + - name: Stage the runtimes shell: pwsh run: | @@ -124,19 +174,43 @@ jobs: - name: Verify every runtime is present shell: pwsh run: | - $required = @('win-x64', 'win-arm64', 'linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64') + $required = @( + 'win-x64', 'win-arm64' + 'linux-x64', 'linux-arm64' + 'osx-x64', 'osx-arm64' + 'android-arm64', 'android-x64' + ) $missing = $required | Where-Object { -not (Get-ChildItem "runtimes/$_/native" -File -ErrorAction SilentlyContinue) } if ($missing) { throw "No native library staged for: $($missing -join ', ')" } Write-Host "All $($required.Count) runtimes present." + # iOS ships as build files rather than runtime assets, so it needs its + # own check. A framework with only the device variant links for + # everyone and then fails for everyone on the simulator. + $framework = './artifacts/apple/box3d.xcframework' + if (-not (Test-Path "$framework/Info.plist")) { + throw "No iOS xcframework was staged at $framework." + } + + $variants = Get-ChildItem $framework -Directory | ForEach-Object { $_.Name } + Write-Host "xcframework variants: $($variants -join ', ')" + + if (-not ($variants | Where-Object { $_ -notlike '*simulator*' })) { + throw 'The xcframework has no device variant.' + } + if (-not ($variants | Where-Object { $_ -like '*simulator*' })) { + throw 'The xcframework has no simulator variant.' + } + - name: Pack run: > dotnet pack --configuration Release --output ./artifacts/packages -p:Version=${{ steps.version.outputs.version }} + -p:Box3DTargetApple=true # Inspect the exact files about to be pushed. A version on nuget.org # cannot be withdrawn, only delisted, so this is the last moment at which @@ -151,6 +225,8 @@ jobs: 'runtimes/linux-arm64/native/libbox3d.so' 'runtimes/osx-x64/native/libbox3d.dylib' 'runtimes/osx-arm64/native/libbox3d.dylib' + 'runtimes/android-arm64/native/libbox3d.so' + 'runtimes/android-x64/native/libbox3d.so' ) Add-Type -AssemblyName System.IO.Compression.FileSystem @@ -181,6 +257,31 @@ jobs: $failed = $true } } + + # iOS travels as build files, not runtime assets, so the + # loop above cannot see it. A package that restores on iOS + # and then cannot link is precisely what this step is the + # last chance to catch. + if (-not ($entries | Where-Object { $_ -like 'buildTransitive/*/Box3D.NET.Native.targets' })) { + Write-Host "::error::$($package.Name) carries no iOS build file" + $failed = $true + } + + foreach ($variant in 'ios-arm64', 'simulator') { + if (-not ($entries | Where-Object { $_ -like "buildTransitive/*/box3d.xcframework/*$variant*/libbox3d.a" })) { + Write-Host "::error::$($package.Name) is missing the $variant archive of box3d.xcframework" + $failed = $true + } + } + + # A static archive under runtimes/ serves no platform: + # nothing loads one at run time. It would only add weight. + foreach ($entry in $entries) { + if ($entry -like 'runtimes/*/*.a') { + Write-Host "::error::$($package.Name) has a static archive under runtimes/: $entry" + $failed = $true + } + } } foreach ($expected in 'README.md', 'LICENSE', 'THIRD-PARTY-NOTICES.txt', 'icon.png') { diff --git a/.gitignore b/.gitignore index 286c15b..3043d25 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,13 @@ coverage*.xml packages/ !packages/build/ +# NuGet's convention for files a package contributes to the consumer's build is +# a folder called build/, which the rule near the top of this file ignores along +# with every CMake output directory. Box3D.NET.Native's is source, not output: +# without this line the iOS .targets is never committed, and the package ships +# looking complete while no iOS application can link against it. +!src/Box3D.NET.Native/build/ + # OS noise .DS_Store Thumbs.db diff --git a/CHANGELOG.md b/CHANGELOG.md index 9047c4d..4982794 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Android and iOS.** The packages now carry native binaries for + `android-arm64`, `android-x64` and iOS, alongside the six desktop runtimes. + + Android needs nothing from the consumer: the `.so` is resolved through the + ordinary NuGet runtime-identifier mechanism and packed into the `.apk`, and + the existing `net8.0` assembly serves it unchanged. Only the 64-bit ABIs are + shipped. `armeabi-v7a` is deliberately absent — Google Play has required + 64-bit for years, and Box3D disables NEON on armv7, which has no divide or + square root, so that ABI would ship a slower scalar build for devices that + cannot be published to anyway. + + iOS is different in kind. Apple does not allow an application to load a + dynamic library that is not a signed framework in its bundle, so Box3D is + shipped as a static archive in an `xcframework` and linked into the + application by a `.targets` file in `Box3D.NET.Native`. The binding therefore + names `__Internal` rather than `box3d` there, which needs a target framework + of its own: the packages now also target `net10.0-ios`. Every other platform + is still served by `net8.0`. .NET 8's and 9's iOS workloads are out of support + and the SDK refuses to build `net8.0-ios` at all, so 10 is the floor for iOS + and only for iOS. + + That framework is off unless asked for, with `-p:Box3DTargetApple=true`, so + building this repository still needs nothing beyond the .NET 8 SDK. The iOS + workload has no Linux host pack — `dotnet workload install ios` fails there + rather than installing something unusable — and making an iOS framework a + hard requirement for building at all would have broken the platform most of + CI runs on. The packaging jobs turn it on, on a runner that can. + + Both are verified in CI against the packed `.nupkg` rather than the + repository: a real Android application is built and its `.apk` opened to + confirm `libbox3d.so` is inside it, and a real iOS application is built and + checked for evidence that the package handed Box3D's archive to the linker. + A clean build proves nothing on iOS by itself — a P/Invoke to `__Internal` is + resolved at run time, so an application the archive never reached builds and + launches like a correct one. Neither runs a simulation on a device, and the + platform table in the README says so rather than implying otherwise. + +### Changed + +- `tools/build-native.ps1` now takes the target platform from the runtime + identifier instead of from the host, since the two stopped being the same + thing. It also builds each target in its own CMake tree — a shared one caches + the first target's toolchain and either fails on the second or, worse, + produces a binary for the wrong target under the right name — and finds the + Android NDK and the CMake and Ninja bundled with the Android SDK on its own. + Android binaries are stripped, which takes each one from about 6 MB to under + 900 KB. + +- The project now states in the README and in both package descriptions that it + was built with AI assistance. + ## [0.3.0] - 2026-08-08 A hardening release. Nothing here is a new capability; it is the release that diff --git a/Directory.Build.props b/Directory.Build.props index cd71d74..fe02413 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -10,6 +10,39 @@ net8.0 + + + net10.0-ios26.0 + + + false + latest enable disable diff --git a/Directory.Build.targets b/Directory.Build.targets index adba54c..87b0955 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -111,11 +111,60 @@ --> + + + + + + + + + + + script --> runtimes --> pkg + script -->|"iOS: static archives"| xcframework --> pkg sub -->|"headers"| gen --> generated --> pkg style sub fill:#d6cdfa,color:#1a1a1a @@ -40,6 +42,16 @@ dotnet test -c Release CI fails if the checked-in generated sources differ from what the scripts produce, which is the point of them. +Every platform but one follows the top path: a shared library staged under +`runtimes//native/`, which is the layout NuGet resolves from at run time. +iOS is the exception, and the second edge exists because of it. Apple does not +allow an application to load a dynamic library that is not a signed framework in +its bundle, so the iOS build produces static archives instead, which are merged +into an `xcframework` and linked into the consuming application by a `.targets` +file the package carries. That is also why the binding names `__Internal` rather +than `box3d` under the iOS target framework: there is no file to load, because +the symbols are already in the application's own executable. + ## The bindings are generated `tools/generate-bindings.ps1` produces the 543 P/Invoke declarations from the diff --git a/docs/getting-started.md b/docs/getting-started.md index 3abd5db..b3db506 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -12,6 +12,21 @@ dotnet add package Box3D.NET The native Box3D binary for your platform comes with it. Nothing else to install. .NET 8 or later, on Windows, Linux and macOS, x64 and arm64. +Android and iOS are supported too, with two caveats worth knowing up front: + +- **Android** works exactly like the desktop platforms — add the package and + the right `libbox3d.so` is packed into your `.apk`. Only 64-bit ABIs are + shipped (`arm64-v8a` and `x86_64`), which covers every publishable device and + the emulator. +- **iOS** requires .NET 10 or later. Apple does not allow an application to load + a dynamic library that is not a signed framework, so Box3D is linked into your + application instead of loaded from a file, and that needs a target framework + the .NET 8 iOS workload can no longer provide. + +Neither is exercised on a real device in CI — see +[Platforms](../README.md#platforms) for exactly what is and is not verified. If +you ship on a phone, test on a phone. + ## Your first simulation ```csharp diff --git a/src/Box3D.NET.Native/Box3D.NET.Native.csproj b/src/Box3D.NET.Native/Box3D.NET.Native.csproj index 0f57342..dfe9f8b 100644 --- a/src/Box3D.NET.Native/Box3D.NET.Native.csproj +++ b/src/Box3D.NET.Native/Box3D.NET.Native.csproj @@ -8,13 +8,61 @@ --> + + + + + net8.0 + net8.0;$(Box3DAppleTargetFramework) + Box3D.NET.Native Box3D.Native true Box3D.NET.Native - Low-level P/Invoke bindings for Box3D, the 3D physics engine by Erin Catto. This package mirrors the C API one-to-one. For an idiomatic C# surface, use the Box3D.NET package instead. + Low-level P/Invoke bindings for Box3D, the 3D physics engine by Erin Catto. This package mirrors the C API one-to-one. For an idiomatic C# surface, use the Box3D.NET package instead. Parts of this project were developed with AI assistance. true + + + diff --git a/src/Box3D.NET.Native/Interop.cs b/src/Box3D.NET.Native/Interop.cs index ac2d05a..4279655 100644 --- a/src/Box3D.NET.Native/Interop.cs +++ b/src/Box3D.NET.Native/Interop.cs @@ -15,11 +15,25 @@ public static class Box3DLibrary /// The native library name passed to . /// /// + /// /// The runtime expands this to box3d.dll on Windows, libbox3d.so - /// on Linux and libbox3d.dylib on macOS, and resolves it from the - /// runtimes/<rid>/native folder of the package. + /// on Linux and Android, and libbox3d.dylib on macOS, and resolves it + /// from the runtimes/<rid>/native folder of the package. + /// + /// + /// On iOS the value is __Internal instead, which names the running + /// executable rather than a file to load. Apple requires every dynamic + /// library inside an application to be a signed framework in the bundle, so + /// the package ships a static archive that the iOS build links into the + /// application: by the time a P/Invoke runs, the Box3D symbols are already + /// part of the main image and there is nothing left to load. + /// /// +#if IOS + public const string Name = "__Internal"; +#else public const string Name = "box3d"; +#endif } /// diff --git a/src/Box3D.NET.Native/build/Box3D.NET.Native.targets b/src/Box3D.NET.Native/build/Box3D.NET.Native.targets new file mode 100644 index 0000000..5c17bf2 --- /dev/null +++ b/src/Box3D.NET.Native/build/Box3D.NET.Native.targets @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + diff --git a/src/Box3D.NET/Box3D.NET.csproj b/src/Box3D.NET/Box3D.NET.csproj index 9aeb74f..71dee09 100644 --- a/src/Box3D.NET/Box3D.NET.csproj +++ b/src/Box3D.NET/Box3D.NET.csproj @@ -7,11 +7,28 @@ --> + + + + + net8.0 + net8.0;$(Box3DAppleTargetFramework) + Box3D.NET Box3D true Box3D.NET - An idiomatic C# wrapper for Box3D, the 3D physics engine by Erin Catto, with no managed allocations on the simulation hot path. Targets .NET 8+, works with NativeAOT and trimming, and ships native binaries for Windows, Linux and macOS on x64 and arm64. + An idiomatic C# wrapper for Box3D, the 3D physics engine by Erin Catto, with no managed allocations on the simulation hot path. Targets .NET 8+, works with NativeAOT and trimming, and ships native binaries for Windows, Linux, macOS, Android and iOS. Parts of this project were developed with AI assistance. diff --git a/tools/build-native.ps1 b/tools/build-native.ps1 index 2fce8dd..f4679d2 100644 --- a/tools/build-native.ps1 +++ b/tools/build-native.ps1 @@ -7,21 +7,29 @@ # never touches those sources; it only configures and builds them. # # Usage: -# pwsh tools/build-native.ps1 # build for this machine -# pwsh tools/build-native.ps1 -Rid linux-arm64 # name the output explicitly -# pwsh tools/build-native.ps1 -MacUniversal # one binary for both Macs +# pwsh tools/build-native.ps1 # build for this machine +# pwsh tools/build-native.ps1 -Rid linux-arm64 # name the output explicitly +# pwsh tools/build-native.ps1 -MacUniversal # one binary for both Macs +# pwsh tools/build-native.ps1 -Rid android-arm64 # cross-compile with the NDK +# pwsh tools/build-native.ps1 -Rid ios-arm64 # static slice for iOS # # Output: -# runtimes//native/box3d.dll (Windows) -# runtimes//native/libbox3d.so (Linux) +# runtimes//native/box3d.dll (Windows) +# runtimes//native/libbox3d.so (Linux, Android) # runtimes//native/libbox3d.dylib (macOS) +# runtimes//native/libbox3d.a (iOS and the iOS simulator) # # That layout is the one NuGet uses to pick the right binary at run time, and -# the one the test and sample projects copy from during a local build. +# the one the test and sample projects copy from during a local build. The iOS +# slices are the exception: a static archive cannot be loaded at run time, so +# they are inputs to tools/create-xcframework.ps1 rather than package assets. +# See the comment above the iOS section for why iOS is static at all. [CmdletBinding()] param( - # The .NET runtime identifier naming the output folder. Inferred when omitted. + # The .NET runtime identifier naming the output folder, and, for the targets + # that cannot be built for the host, the platform being cross-compiled for. + # Inferred from the host when omitted. [string] $Rid, # Build configuration for the native library. @@ -34,8 +42,14 @@ param( # The CMake generator to use. Left empty, CMake picks its default, which is # Visual Studio on a machine that has it. Set this to build with another # toolchain, for example: -Generator Ninja with gcc or clang on PATH. + # + # Android ignores CMake's default and always uses Ninja; see below. [string] $Generator, + # The Android NDK to cross-compile with. Discovered from the usual + # environment variables and install locations when omitted. + [string] $AndroidNdk, + # Remove the CMake build tree before configuring. [switch] $Clean ) @@ -44,37 +58,19 @@ $ErrorActionPreference = 'Stop' $RepoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) $SourceDir = Join-Path $RepoRoot 'external/box3d' -$BuildDir = Join-Path $RepoRoot 'artifacts/native-build' if (-not (Test-Path (Join-Path $SourceDir 'CMakeLists.txt'))) { throw "Box3D sources not found at $SourceDir. Run: git submodule update --init --recursive" } -if (-not (Get-Command cmake -ErrorAction SilentlyContinue)) { - throw 'CMake was not found on PATH. Install CMake 3.22 or later: https://cmake.org/download/' -} - # ------------------------------------------------------------- platform facts -# The canonical file name is the one the package ships and the one .NET resolves -# first. The search patterns are wider than that because the name depends on the -# toolchain: MSVC emits box3d.dll while MinGW emits libbox3d.dll for the same -# target. Whatever is produced is staged under the canonical name. -if ($IsWindows -or $env:OS -eq 'Windows_NT') { - $platform = 'windows' - $libraryName = 'box3d.dll' - $searchPatterns = @('box3d.dll', 'libbox3d.dll') -} -elseif ($IsMacOS) { - $platform = 'macos' - $libraryName = 'libbox3d.dylib' - $searchPatterns = @('libbox3d.dylib', 'box3d.dylib') -} -else { - $platform = 'linux' - $libraryName = 'libbox3d.so' - $searchPatterns = @('libbox3d.so', 'box3d.so') -} +# The host only decides which targets are reachable. Everything else is driven +# by the RID, because the platform being built for and the platform doing the +# building stopped being the same thing once Android and iOS were added. +if ($IsWindows -or $env:OS -eq 'Windows_NT') { $hostPlatform = 'windows' } +elseif ($IsMacOS) { $hostPlatform = 'macos' } +else { $hostPlatform = 'linux' } if (-not $Rid) { $arch = switch ([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture) { @@ -83,19 +79,148 @@ if (-not $Rid) { 'X86' { 'x86' } default { throw "Unsupported process architecture: $_" } } - $Rid = switch ($platform) { + $Rid = switch ($hostPlatform) { 'windows' { "win-$arch" } 'macos' { "osx-$arch" } 'linux' { "linux-$arch" } } } +$platform = switch -Wildcard ($Rid) { + 'win-*' { 'windows' } + 'osx-*' { 'macos' } + 'linux-*' { 'linux' } + 'android-*' { 'android' } + 'ios-*' { 'ios' } + 'iossimulator-*' { 'ios' } + default { throw "Unrecognised runtime identifier: $Rid" } +} + +# The canonical file name is the one the package ships and the one .NET resolves +# first. The search patterns are wider than that because the name depends on the +# toolchain: MSVC emits box3d.dll while MinGW emits libbox3d.dll for the same +# target. Whatever is produced is staged under the canonical name. +switch ($platform) { + 'windows' { $libraryName = 'box3d.dll'; $searchPatterns = @('box3d.dll', 'libbox3d.dll') } + 'macos' { $libraryName = 'libbox3d.dylib'; $searchPatterns = @('libbox3d.dylib', 'box3d.dylib') } + 'linux' { $libraryName = 'libbox3d.so'; $searchPatterns = @('libbox3d.so', 'box3d.so') } + 'android' { $libraryName = 'libbox3d.so'; $searchPatterns = @('libbox3d.so', 'box3d.so') } + 'ios' { $libraryName = 'libbox3d.a'; $searchPatterns = @('libbox3d.a', 'box3d.a') } +} + +# Cross-compiling is only possible where the toolchain exists. Apple's is +# available on macOS alone, which is why the iOS slices are built on CI rather +# than wherever the release happens to be cut. +if ($platform -eq 'ios' -and $hostPlatform -ne 'macos') { + throw "Building for $Rid requires macOS with Xcode installed; this is $hostPlatform." +} +if ($platform -eq 'macos' -and $hostPlatform -ne 'macos') { + throw "Building for $Rid requires macOS; this is $hostPlatform." +} + +# Each target gets its own build tree. A single shared one appears to work until +# the second target is built into it: CMake caches the compiler, the sysroot and +# the toolchain file on the first configure, and reconfiguring with a different +# toolchain over that cache either fails outright or, worse, silently produces a +# binary for the previous target under the new RID's name. +$BuildDir = Join-Path $RepoRoot "artifacts/native-build/$Rid" + +# ------------------------------------------------------------------ toolchain + +# Android ships a complete CMake and Ninja inside the SDK, so a machine set up +# for Android development can build this without a separate CMake install. The +# one on PATH still wins where there is one. +function Find-AndroidSdk { + foreach ($candidate in @($env:ANDROID_HOME, $env:ANDROID_SDK_ROOT)) { + if ($candidate -and (Test-Path $candidate)) { return $candidate } + } + + $defaults = switch ($hostPlatform) { + 'windows' { @("$env:LOCALAPPDATA/Android/Sdk") } + 'macos' { @("$HOME/Library/Android/sdk") } + 'linux' { @("$HOME/Android/Sdk", "$HOME/android-sdk") } + } + + foreach ($candidate in $defaults) { + if ($candidate -and (Test-Path $candidate)) { return $candidate } + } + + return $null +} + +# Picks the highest version from a directory of side-by-side version folders, +# which is how both the NDK and the SDK's CMake are laid out. Sorting these as +# strings puts 3.9 above 3.22, so they are compared as versions. +function Get-NewestVersionedChild([string] $Root) { + if (-not $Root -or -not (Test-Path $Root)) { return $null } + + return Get-ChildItem -Path $Root -Directory | + Sort-Object { try { [version] $_.Name } catch { [version] '0.0' } } -Descending | + Select-Object -First 1 | + ForEach-Object { $_.FullName } +} + +$androidSdk = $null +if ($platform -eq 'android') { + if (-not $AndroidNdk) { + # ANDROID_NDK_LATEST_HOME is what the GitHub-hosted runners set; the + # other two are what a local install of the NDK sets. + foreach ($candidate in @($env:ANDROID_NDK_HOME, $env:ANDROID_NDK_ROOT, $env:ANDROID_NDK_LATEST_HOME)) { + if ($candidate -and (Test-Path $candidate)) { $AndroidNdk = $candidate; break } + } + } + + $androidSdk = Find-AndroidSdk + + if (-not $AndroidNdk -and $androidSdk) { + $AndroidNdk = Get-NewestVersionedChild (Join-Path $androidSdk 'ndk') + } + + if (-not $AndroidNdk) { + throw 'The Android NDK was not found. Set ANDROID_NDK_HOME, or pass -AndroidNdk, or install the NDK through the Android SDK manager.' + } + + $androidToolchain = Join-Path $AndroidNdk 'build/cmake/android.toolchain.cmake' + if (-not (Test-Path $androidToolchain)) { + throw "No CMake toolchain file at $androidToolchain. That path does not look like an Android NDK." + } +} + +# Resolved the long way round rather than with ?. because Windows PowerShell 5.1 +# has no null-conditional operator, and this script is expected to run there. +$cmakeCommand = Get-Command cmake -ErrorAction SilentlyContinue +$ninjaCommand = Get-Command ninja -ErrorAction SilentlyContinue +$cmakeExe = if ($cmakeCommand) { $cmakeCommand.Source } else { $null } +$ninjaExe = if ($ninjaCommand) { $ninjaCommand.Source } else { $null } + +if ($platform -eq 'android' -and $androidSdk) { + $sdkCMake = Get-NewestVersionedChild (Join-Path $androidSdk 'cmake') + if ($sdkCMake) { + $suffix = if ($hostPlatform -eq 'windows') { '.exe' } else { '' } + if (-not $cmakeExe) { + $bundled = Join-Path $sdkCMake "bin/cmake$suffix" + if (Test-Path $bundled) { $cmakeExe = $bundled } + } + if (-not $ninjaExe) { + $bundled = Join-Path $sdkCMake "bin/ninja$suffix" + if (Test-Path $bundled) { $ninjaExe = $bundled } + } + } +} + +if (-not $cmakeExe) { + throw 'CMake was not found on PATH. Install CMake 3.22 or later: https://cmake.org/download/' +} + $OutputDir = Join-Path $RepoRoot "runtimes/$Rid/native" Write-Host "Box3D native build" Write-Host " source : $SourceDir" Write-Host " configuration : $Configuration" Write-Host " runtime id : $Rid" +Write-Host " host : $hostPlatform" +Write-Host " cmake : $cmakeExe" +if ($platform -eq 'android') { Write-Host " android ndk : $AndroidNdk" } Write-Host " output : $OutputDir" if ($Clean -and (Test-Path $BuildDir)) { @@ -110,10 +235,14 @@ if ($Clean -and (Test-Path $BuildDir)) { # # BOX3D_VALIDATE defaults to ON upstream and adds heavy internal checking. It is # left off here so that a Release package is not paying for assertions. +# +# Box3D picks its SIMD path from the compiler's own macros rather than from +# anything set here, so each target gets the right one without help: NEON on +# arm64, SSE2 on x64, and the scalar path on armv7, where upstream disables NEON +# because it has no divide or square root. $cmakeArgs = @( '-S', $SourceDir '-B', $BuildDir - '-DBUILD_SHARED_LIBS=ON' '-DCMAKE_BUILD_TYPE=' + $Configuration '-DCMAKE_POSITION_INDEPENDENT_CODE=ON' '-DBOX3D_SAMPLES=OFF' @@ -126,6 +255,19 @@ $cmakeArgs = @( '-DBOX3D_DOUBLE_PRECISION=OFF' ) +# iOS is the one target that is linked statically rather than shipped as a +# loadable library. Apple requires every dynamic library inside an application +# to be a signed framework in the bundle, and the .NET iOS build links native +# dependencies into the executable instead. That is also why the binding names +# __Internal rather than box3d on this platform: by the time the P/Invoke runs, +# the symbols are already in the main image. +if ($platform -eq 'ios') { + $cmakeArgs += '-DBUILD_SHARED_LIBS=OFF' +} +else { + $cmakeArgs += '-DBUILD_SHARED_LIBS=ON' +} + if ($platform -eq 'macos') { if ($MacUniversal) { $cmakeArgs += '-DCMAKE_OSX_ARCHITECTURES=x86_64;arm64' @@ -141,6 +283,56 @@ if ($platform -eq 'macos') { $cmakeArgs += '-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0' } +if ($platform -eq 'ios') { + # Device and simulator are different sysroots, not different architectures, + # and a slice built against the wrong one is rejected by the linker rather + # than at run time. The RID carries which is which. + $sysroot = if ($Rid -like 'iossimulator-*') { 'iphonesimulator' } else { 'iphoneos' } + $arch = if ($Rid -like '*-x64') { 'x86_64' } else { 'arm64' } + + $cmakeArgs += @( + '-DCMAKE_SYSTEM_NAME=iOS' + "-DCMAKE_OSX_SYSROOT=$sysroot" + "-DCMAKE_OSX_ARCHITECTURES=$arch" + # Matches the minimum that .NET 8's iOS workload targets. A lower value + # would produce a library the application cannot deploy against. + '-DCMAKE_OSX_DEPLOYMENT_TARGET=12.2' + ) + + # CMake's default generator on macOS is Makefiles, which does build this, + # but Xcode is the generator Apple's toolchain is tested against and the one + # that gets the simulator sysroot and bitcode-era defaults right. + if (-not $Generator) { $Generator = 'Xcode' } +} + +if ($platform -eq 'android') { + $abi = switch ($Rid) { + 'android-arm64' { 'arm64-v8a' } + 'android-x64' { 'x86_64' } + 'android-arm' { 'armeabi-v7a' } + 'android-x86' { 'x86' } + default { throw "No Android ABI is mapped to the runtime identifier $Rid." } + } + + $cmakeArgs += @( + "-DCMAKE_TOOLCHAIN_FILE=$androidToolchain" + "-DANDROID_ABI=$abi" + # API 21 is the floor .NET for Android supports, so building lower would + # buy nothing the managed side could use. + '-DANDROID_PLATFORM=android-21' + ) + + # The NDK toolchain only supports single-configuration generators, so + # CMake's default is wrong here on every host that has Visual Studio. + if (-not $Generator) { + if (-not $ninjaExe) { + throw 'Ninja was not found. It is required to build for Android; it ships inside the Android SDK''s CMake package, or install it separately.' + } + $Generator = 'Ninja' + $cmakeArgs += "-DCMAKE_MAKE_PROGRAM=$ninjaExe" + } +} + if ($Generator) { $cmakeArgs += @('-G', $Generator) } @@ -172,7 +364,7 @@ elseif ($platform -eq 'windows') { } Write-Host "`n> cmake $($cmakeArgs -join ' ')" -& cmake @cmakeArgs +& $cmakeExe @cmakeArgs if ($LASTEXITCODE -ne 0) { throw "CMake configure failed with exit code $LASTEXITCODE" } # ---------------------------------------------------------------------- build @@ -183,7 +375,7 @@ $buildArgs = @('--build', $BuildDir, '--config', $Configuration, '--target', 'bo $buildArgs += @('--parallel') Write-Host "`n> cmake $($buildArgs -join ' ')" -& cmake @buildArgs +& $cmakeExe @buildArgs if ($LASTEXITCODE -ne 0) { throw "CMake build failed with exit code $LASTEXITCODE" } # ---------------------------------------------------------------------- stage @@ -211,6 +403,26 @@ if ($platform -eq 'windows') { if ($pdb) { Copy-Item $pdb.FullName (Join-Path $OutputDir 'box3d.pdb') -Force } } +# An unstripped Release build of Box3D is around 6 MB per ABI, nearly all of it +# symbol and debug data that nothing on the device reads. That cost lands in +# every installed application, once per ABI shipped, so it is removed here. +# +# Only Android. The iOS output is a static archive whose symbols the linker +# still needs, and the desktop packages keep theirs so that a crash in the +# native library has a usable stack. +if ($platform -eq 'android') { + $stripTool = Get-ChildItem -Path (Join-Path $AndroidNdk 'toolchains/llvm/prebuilt') -Recurse -Filter 'llvm-strip*' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 + + if ($stripTool) { + & $stripTool.FullName '--strip-unneeded' (Join-Path $OutputDir $libraryName) + if ($LASTEXITCODE -ne 0) { throw "llvm-strip failed with exit code $LASTEXITCODE" } + } + else { + Write-Warning "llvm-strip was not found under $AndroidNdk. Staging the unstripped library, which is several times larger than it needs to be." + } +} + # Measure what was staged, not what was found. # # On macOS CMake writes libbox3d..dylib and leaves libbox3d.dylib as a @@ -226,3 +438,7 @@ if ($staged.Length -eq 0) { } Write-Host "`nStaged $libraryName ($size KB) to $OutputDir" + +if ($platform -eq 'ios') { + Write-Host "This is a static slice, not a package asset. Run tools/create-xcframework.ps1 once every iOS slice has been built." +} diff --git a/tools/create-xcframework.ps1 b/tools/create-xcframework.ps1 new file mode 100644 index 0000000..286844e --- /dev/null +++ b/tools/create-xcframework.ps1 @@ -0,0 +1,124 @@ +#!/usr/bin/env pwsh +# SPDX-License-Identifier: MIT +# +# Assembles the iOS slices staged by build-native.ps1 into the xcframework that +# the package ships. +# +# Usage: +# pwsh tools/create-xcframework.ps1 +# +# Input: +# runtimes/ios-arm64/native/libbox3d.a +# runtimes/iossimulator-arm64/native/libbox3d.a +# runtimes/iossimulator-x64/native/libbox3d.a +# +# Output: +# artifacts/apple/box3d.xcframework +# +# This is a separate script rather than a step inside build-native.ps1 because +# it cannot run until every slice exists, and each slice is built by its own +# invocation - on CI, potentially in its own job. + +[CmdletBinding()] +param( + # Build the framework from whichever slices are present instead of failing + # on a missing one. Intended for trying out a single-architecture build + # locally; a package built this way does not support every iOS device. + [switch] $AllowPartial +) + +$ErrorActionPreference = 'Stop' + +$RepoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$OutputDir = Join-Path $RepoRoot 'artifacts/apple' +$Output = Join-Path $OutputDir 'box3d.xcframework' + +# xcodebuild and lipo are Xcode's, and Xcode is macOS only. There is no fallback +# to write here: an xcframework is an Apple packaging format that only Apple's +# tooling produces correctly. +if (-not $IsMacOS) { + throw 'Creating an xcframework requires macOS with Xcode installed.' +} + +foreach ($tool in @('xcodebuild', 'lipo')) { + if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) { + throw "$tool was not found on PATH. Install Xcode and its command line tools." + } +} + +function Get-Slice([string] $Rid) { + $path = Join-Path $RepoRoot "runtimes/$Rid/native/libbox3d.a" + if (Test-Path $path) { return $path } + + if (-not $AllowPartial) { + throw "No archive staged for $Rid at $path. Run: pwsh tools/build-native.ps1 -Rid $Rid" + } + + Write-Warning "No archive staged for $Rid. The framework will not support it." + return $null +} + +$device = Get-Slice 'ios-arm64' +$simulatorArm = Get-Slice 'iossimulator-arm64' +$simulatorX64 = Get-Slice 'iossimulator-x64' + +if (-not $device -and -not $simulatorArm -and -not $simulatorX64) { + throw 'No iOS archives are staged. Nothing to assemble.' +} + +if (Test-Path $Output) { Remove-Item -Recurse -Force $Output } +New-Item -ItemType Directory -Force $OutputDir | Out-Null + +# The two simulator archives have to become one before xcodebuild sees them. +# +# An xcframework is indexed by platform, not by architecture, and the simulator +# is a single platform: passing the arm64 and x86_64 simulator archives as two +# -library arguments is rejected as a duplicate rather than merged. lipo is what +# merges architectures; xcodebuild is what separates platforms. +$simulator = $null +$simulatorSlices = @($simulatorArm, $simulatorX64) | Where-Object { $_ } + +# The merged archive is written into a directory of its own and keeps the name +# libbox3d.a. xcodebuild copies each -library argument into the framework under +# the file name it arrived with, so calling this one libbox3d-simulator.a +# produced a framework whose two variants held differently named archives - +# valid, but inconsistent enough that anything matching on the file name sees +# one variant and not the other. +$merged = Join-Path $OutputDir 'merged-simulator' + +if ($simulatorSlices.Count -gt 1) { + New-Item -ItemType Directory -Force $merged | Out-Null + $simulator = Join-Path $merged 'libbox3d.a' + + Write-Host "> lipo -create $($simulatorSlices -join ' ') -output $simulator" + & lipo -create @simulatorSlices -output $simulator + if ($LASTEXITCODE -ne 0) { throw "lipo failed with exit code $LASTEXITCODE" } +} +elseif ($simulatorSlices.Count -eq 1) { + $simulator = $simulatorSlices[0] +} + +$xcodeArgs = @('-create-xcframework') +if ($device) { $xcodeArgs += @('-library', $device) } +if ($simulator) { $xcodeArgs += @('-library', $simulator) } +$xcodeArgs += @('-output', $Output) + +Write-Host "`n> xcodebuild $($xcodeArgs -join ' ')" +& xcodebuild @xcodeArgs +if ($LASTEXITCODE -ne 0) { throw "xcodebuild failed with exit code $LASTEXITCODE" } + +# The merged archive is an intermediate. Left in place it would be packed into +# the NuGet package along with the framework directory beside it, doubling the +# simulator payload for no reason. +if (Test-Path $merged) { + Remove-Item -Recurse -Force $merged +} + +# Report what the framework actually covers rather than what was asked for, +# which is the part that matters when -AllowPartial was used. +Write-Host "`nCreated $Output" +Get-ChildItem -Path $Output -Directory | ForEach-Object { + $archive = Join-Path $_.FullName 'libbox3d.a' + $architectures = if (Test-Path $archive) { (& lipo -archs $archive) } else { 'no archive' } + Write-Host " $($_.Name): $architectures" +} diff --git a/tools/verify-package-mobile.ps1 b/tools/verify-package-mobile.ps1 new file mode 100644 index 0000000..b06367d --- /dev/null +++ b/tools/verify-package-mobile.ps1 @@ -0,0 +1,432 @@ +#!/usr/bin/env pwsh +# SPDX-License-Identifier: MIT +# +# Consumes the built packages from a real Android or iOS application, and proves +# that Box3D actually ends up inside it. +# +# verify-package.ps1 is the desktop counterpart and cannot cover these: it +# publishes an executable and runs it, and neither platform has an executable a +# CI runner can start. What can be checked without a device is the part that +# actually differs on mobile - whether the native library reaches the artifact +# the user installs - and it is checked by opening that artifact rather than by +# trusting a green build: +# +# Android the .apk is a zip; lib//libbox3d.so has to be in it +# iOS the library is linked into the executable, so its symbols have +# to be in the built binary +# +# Both failures are silent otherwise. An Android build with the runtime asset +# unresolved produces a perfectly valid apk that dies on the first physics call, +# and an iOS build whose static archive was dropped by the linker produces a +# perfectly valid app that does the same. +# +# Usage: +# pwsh tools/verify-package-mobile.ps1 -Platform Android +# pwsh tools/verify-package-mobile.ps1 -Platform iOS + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateSet('Android', 'iOS')] + [string] $Platform, + + # The package version to install. Inferred from the packages present when + # omitted. + [string] $Version, + + # Where the .nupkg files are. + [string] $PackageDirectory = 'artifacts/packages', + + # Where to build the consumer. Removed and recreated on each run. + [string] $WorkDirectory +) + +$ErrorActionPreference = 'Stop' + +$RepoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$PackageDirectory = Join-Path $RepoRoot $PackageDirectory + +if (-not (Test-Path $PackageDirectory)) { + throw "No package directory at $PackageDirectory. Run: dotnet pack --configuration Release --output artifacts/packages" +} + +if (-not $Version) { + $packages = Get-ChildItem $PackageDirectory -Filter 'Box3D.NET.*.nupkg' | + Where-Object { $_.Name -match '^Box3D\.NET\.(\d+\.\d+\.\d+.*)\.nupkg$' } + + if (-not $packages) { + throw "No Box3D.NET package found in $PackageDirectory." + } + + $Version = [regex]::Match($packages[0].Name, '^Box3D\.NET\.(.+)\.nupkg$').Groups[1].Value +} + +# Linking an iOS application is Xcode's job, and Xcode is macOS only. Android +# cross-compiles from anywhere the workload installs. +if ($Platform -eq 'iOS' -and -not $IsMacOS) { + throw 'Verifying the iOS package requires macOS with Xcode installed.' +} + +if (-not $WorkDirectory) { + $WorkDirectory = Join-Path $RepoRoot "artifacts/package-consumer-$($Platform.ToLowerInvariant())" +} + +Write-Host "Mobile package consumer verification" +Write-Host " platform : $Platform" +Write-Host " packages : $PackageDirectory" +Write-Host " version : $Version" +Write-Host " work dir : $WorkDirectory" + +if (Test-Path $WorkDirectory) { + Remove-Item -Recurse -Force $WorkDirectory +} +New-Item -ItemType Directory -Force $WorkDirectory | Out-Null + +# ------------------------------------------------------------ the consumer + +# The same folder feed the desktop verification uses, and for the same reason: +# with the result cannot depend on feeds this machine happens to have. +@" + + + + + + + + +"@ | Set-Content -Path (Join-Path $WorkDirectory 'nuget.config') -Encoding utf8 + +# Cut this project off from the repository's own build configuration. +# +# The consumer is supposed to be an ordinary application that has never heard of +# Box3D.NET, and it is not one while it sits under artifacts/ inheriting the +# props above it: TreatWarningsAsErrors, the analyzers and the documentation +# gate all applied to it. The iOS template ships AppDelegate and SceneDelegate, +# CA1711 objects to both names, and the build failed on the repository's rules +# rather than on anything to do with the package. +# +# MSBuild stops at the first Directory.Build.props it finds walking up, so an +# empty one here ends the search. The desktop script does the same job by +# setting ImportDirectoryBuildProps in a project it writes itself; this one +# cannot, because the project comes from a template. +foreach ($name in 'Directory.Build.props', 'Directory.Build.targets') { + @" + + + +"@ | Set-Content -Path (Join-Path $WorkDirectory $name) -Encoding utf8 +} + +# The workload's own template, rather than a hand-written project. An Android +# application needs a manifest, an activity and a resource tree, and an iOS one +# needs an Info.plist and a scene delegate; reproducing those here would be +# reproducing something that changes with every workload release. +$template = if ($Platform -eq 'Android') { 'android' } else { 'ios' } + +Push-Location $WorkDirectory +try { + Write-Host "`n> dotnet new $template" + & dotnet new $template --name consumer --output . --force + if ($LASTEXITCODE -ne 0) { throw "dotnet new $template failed with exit code $LASTEXITCODE. Is the $template workload installed?" } + + Write-Host "`n> dotnet add package Box3D.NET --version $Version" + & dotnet add package Box3D.NET --version $Version + if ($LASTEXITCODE -ne 0) { throw "dotnet add package failed with exit code $LASTEXITCODE" } + + # Something in the application has to call into Box3D, and the call has to be + # reachable from code the application actually keeps. + # + # Not for the sake of running it - nothing here runs - but because an + # unreachable call is trimmed away, and on iOS that silently undoes the + # linking this whole check exists to prove. Every link in that chain + # succeeds, which is why it is worth spelling out: + # + # the trimmer drops Box3DUse, since nothing the application reaches refers + # to it, and the P/Invokes go with it + # + # mtouch builds the list of symbols to keep from the __Internal P/Invokes + # it finds in what survives - it finds none, so no b3 symbol is listed + # + # the native link runs with -force_load, which pulls every object of + # libbox3d.a in, and then -dead_strip removes all of them again: nothing + # refers to them by name, because a P/Invoke to __Internal is resolved at + # run time, and the exported symbol list does not ask for them either + # + # The result builds, installs and launches, and dies on the first physics + # call. A module initializer is what anchors it: ILLink keeps the module's + # own initializer for the assembly being built, so the call below is rooted + # without this script having to find and edit the template's entry point, + # which is a shape that changes with the workload. + @' +// SPDX-License-Identifier: MIT +using System.Numerics; +using System.Runtime.CompilerServices; +using Box3D; + +internal static class Box3DUse +{ + internal static float Result; + + [ModuleInitializer] + internal static void Run() + { + Result = Fall(); + } + + internal static float Fall() + { + using var world = new PhysicsWorld(WorldSettings.Default with + { + Gravity = new Vector3(0.0f, -9.81f, 0.0f), + }); + + Body ball = world.CreateDynamicBody(new Vector3(0.0f, 10.0f, 0.0f)); + ball.AddSphere(new Sphere(0.5f), ShapeDefinition.Default); + + for (int frame = 0; frame < 60; frame++) + { + world.Step(1.0f / 60.0f); + } + + return ball.Position.Y; + } +} +'@ | Set-Content -Path (Join-Path $WorkDirectory 'Box3DUse.cs') -Encoding utf8 + + # A second anchor for the same call, from the template's entry point. + # + # The module initializer above is what the check relies on. This adds the + # call an application would really contain, on the line before the template + # hands control to UIKit, and it is deliberately best effort: the file that + # calls UIApplication.Main is the workload's to write, and it has already + # changed spelling once. When it cannot be found, what is reported is that + # this anchor is missing rather than a failure - the symbol check at the end + # is the judge either way, and it cannot be fooled by a missing anchor. + if ($Platform -eq 'iOS') { + # obj and bin are excluded because both fill with generated sources + # during the restore that has already run, and one of those is a far + # worse place to edit than the template's own file. + $sources = @(Get-ChildItem -Path $WorkDirectory -Filter '*.cs' -File -Recurse | + Where-Object { $_.FullName -notmatch '[\\/](obj|bin)[\\/]' }) + + $entryPoint = $sources | + Where-Object { (Get-Content -Raw $_.FullName) -match 'UIApplication\.Main\s*\(' } | + Select-Object -First 1 + + $patched = $null + if ($entryPoint) { + # Inserted ahead of the call rather than written over the file, so + # the template keeps its own namespace and its own AppDelegate. + $source = Get-Content -Raw $entryPoint.FullName + $patched = ([regex] '(?m)^([ \t]*)(UIApplication\.Main\s*\()').Replace($source, "`$1global::Box3DUse.Fall();`r`n`r`n`$1`$2", 1) + + if ($patched -ne $source) { + Set-Content -Path $entryPoint.FullName -Value $patched -Encoding utf8 + Write-Host "`nCalled Box3D from $($entryPoint.Name) as well as from the module initializer." + } + } + + if (-not $entryPoint -or $patched -eq $source) { + Write-Host "`nNo entry point to call Box3D from - the module initializer is the only anchor." + Write-Host ' sources the template produced:' + $sources | ForEach-Object { Write-Host " $($_.FullName.Substring($WorkDirectory.Length + 1))" } + } + } + + $buildArgs = @('build', '--configuration', 'Release') + + # The simulator, because a device build needs a signing identity and a + # provisioning profile, which a CI runner has no business holding. The + # linking question is the same either way: the archive is either in the + # executable or it is not. + if ($Platform -eq 'iOS') { + $buildArgs += @('-p:RuntimeIdentifier=iossimulator-arm64') + } + + # The log is kept because it is evidence in its own right on iOS: whether + # the linker was handed the archive at all is visible there, and nowhere in + # the finished bundle if the answer is no. + $buildLog = Join-Path $WorkDirectory 'build.log' + $buildArgs += @('-v:n', "-fileLoggerParameters:LogFile=$buildLog;Verbosity=normal") + + Write-Host "`n> dotnet $($buildArgs -join ' ')" + & dotnet @buildArgs + if ($LASTEXITCODE -ne 0) { throw "The consumer application failed to build with exit code $LASTEXITCODE" } + + # ------------------------------------------------------------ inspection + + if ($Platform -eq 'Android') { + $apk = Get-ChildItem -Path (Join-Path $WorkDirectory 'bin/Release') -Recurse -Filter '*.apk' -File | + Sort-Object Length -Descending | Select-Object -First 1 + + if (-not $apk) { + throw 'The build produced no .apk to inspect.' + } + + Write-Host "`nInspecting $($apk.Name) ($([math]::Round($apk.Length / 1MB, 1)) MB)" + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [System.IO.Compression.ZipFile]::OpenRead($apk.FullName) + try { + $libraries = $zip.Entries.FullName | Where-Object { $_ -like '*libbox3d.so' } + } + finally { + $zip.Dispose() + } + + if (-not $libraries) { + throw "$($apk.Name) contains no libbox3d.so. The package's Android runtime asset was not resolved into the application." + } + + $libraries | ForEach-Object { Write-Host " $_" } + + # An apk built for a single ABI is legitimate; one missing the ABI every + # current phone uses is not. + if (-not ($libraries | Where-Object { $_ -like '*arm64-v8a*' })) { + throw 'The apk carries libbox3d.so but not for arm64-v8a, which is the ABI every current Android device runs.' + } + + Write-Host "`nThe Android application carries Box3D for every ABI listed above." + } + else { + $app = Get-ChildItem -Path (Join-Path $WorkDirectory 'bin/Release') -Recurse -Directory -Filter '*.app' | + Select-Object -First 1 + + if (-not $app) { + throw 'The build produced no .app bundle to inspect.' + } + + # The executable inside the bundle shares its name, minus the extension. + $executable = Join-Path $app.FullName ([System.IO.Path]::GetFileNameWithoutExtension($app.Name)) + if (-not (Test-Path $executable)) { + throw "No executable inside $($app.Name)." + } + + Write-Host "`nInspecting $executable ($([math]::Round((Get-Item $executable).Length / 1MB, 1)) MB)" + + # This is the check that matters on iOS, and it is asked two ways + # because a build that succeeds proves nothing on its own. A P/Invoke to + # __Internal is resolved by Mono at run time, not by the linker, so an + # application whose archive was never handed to the linker builds, + # installs and launches exactly like a correct one, and fails on the + # first physics call. There is no device here to find that out on. + # + # the link did the build hand libbox3d.a to the linker at all, + # which is what the package's .targets is responsible for + # the result did Box3D's symbols end up in the executable, which is + # what ForceLoad is responsible for: without it the linker + # keeps only the objects something already refers to, and + # nothing refers to these until run time + # + # Either one passing means the archive reached the application. Both are + # reported, because which one failed says which half to go and look at. + $linkerSawArchive = $false + if (Test-Path $buildLog) { + $linkerSawArchive = [bool] (Select-String -Path $buildLog -Pattern 'libbox3d\.a|box3d\.xcframework' -Quiet) + } + + $allSymbols = @(& nm $executable 2>$null) + + # Undefined entries are what the binary imports from the system, and a + # fully stripped executable still lists those. Only defined symbols say + # anything about what is inside it, so they are counted separately: none + # at all means the check cannot see anything and silence proves nothing. + $definedSymbols = @($allSymbols | Where-Object { $_ -notmatch '^\s*U ' -and $_ -match '^[0-9a-fA-F]+\s' }) + + # Two different things are counted here, and only one of them answers + # what this job exists to ask. + # + # native a defined symbol whose name starts _b3. That is the C API's + # own naming, so it came out of libbox3d.a and nowhere else + # managed the AOT compiler's output for Box3D's C#, named like + # _Box3D_NET_Box3D_Body__ctor_Box3D_Native_b3BodyId + # + # Telling them apart is the whole point. A plain search for b3 matches + # the managed ones by accident, because a mangled name carries its + # parameter types and b3BodyId or b3ShapeDef sit inside them - and those + # symbols come from the managed assembly, so they would be in the binary + # whether or not the archive survived the link. Counting them as proof + # of linking counts the wrong thing. + # + # The managed count is still worth having. It says whether the trimmer + # kept Box3D's C# at all, which is the other way this check has failed, + # and it separates the two causes when the native count is zero. + $nativeSymbols = @($definedSymbols | Where-Object { $_ -match '^[0-9a-fA-F]+\s+\S+\s+_b3[A-Za-z_]' }) + $managedSymbols = @($definedSymbols | Where-Object { $_ -match '_Box3D_NET_' }) + + Write-Host " archive passed to the linker : $linkerSawArchive" + Write-Host " defined symbols in binary : $($definedSymbols.Count)" + Write-Host " Box3D native symbols : $($nativeSymbols.Count)" + Write-Host " Box3D managed AOT symbols : $($managedSymbols.Count)" + + if ($nativeSymbols.Count -gt 0) { + $nativeSymbols | Select-Object -First 5 | ForEach-Object { Write-Host " $($_.Trim())" } + Write-Host "`nThe iOS application has Box3D linked into its executable." + } + elseif ($definedSymbols.Count -eq 0) { + # Nothing is visible either way. Release builds for iOS are + # stripped, so this is expected rather than suspicious, but it does + # mean the stronger half of the check did not run and saying "pass" + # without saying that would be claiming more than was tested. + if (-not $linkerSawArchive) { + throw 'Box3D never reached the application: the linker was not given libbox3d.a, and the executable is stripped so nothing else can be read from it. The package iOS .targets did not take effect - check that buildTransitive// matches the consumer target framework, and that NativeReference names the xcframework correctly.' + } + + Write-Host "`nThe linker was given Box3D's archive. The executable is stripped - it defines no symbols at all - so the symbol check could not run; the link is the evidence here." + } + else { + # The binary kept its symbol table and no native Box3D symbol is in + # it. That is not a stripped build hiding the evidence, it is the + # evidence: whatever the linker was given did not end up in the + # application, and every physics call would fail on the device with + # an entry point that is not there. + # + # Which half to go and fix is decided by two things gathered here. + # The managed count says whether the application still contains the + # C# that P/Invokes at all, and mtouch-symbols.list - the file + # passed to the native link as -exported_symbols_list - says whether + # anything asked the linker to keep the native symbols, since + # -dead_strip keeps only what that file names. + Write-Host "`n--- diagnostics" + + $requested = @() + $symbolList = Get-ChildItem -Path (Join-Path $WorkDirectory 'obj') -Recurse -Filter 'mtouch-symbols.list' -File | + Select-Object -First 1 + + if ($symbolList) { + $listed = @(Get-Content $symbolList.FullName) + $requested = @($listed | Select-String -Pattern '^_?b3[A-Za-z_]') + Write-Host " mtouch-symbols.list : $($requested.Count) Box3D of $($listed.Count) symbols asked for" + $requested | Select-Object -First 5 | ForEach-Object { Write-Host " $($_.Line.Trim())" } + } + else { + Write-Host " mtouch-symbols.list : not found under obj/" + } + + if (Test-Path $buildLog) { + Write-Host " native link invocations naming the archive:" + Select-String -Path $buildLog -Pattern 'force_load' | + Select-Object -First 3 | + ForEach-Object { Write-Host " $(($_.Line.Trim() -split '\s+' | Select-Object -First 6) -join ' ') ..." } + } + + if ($managedSymbols.Count -eq 0) { + throw "The executable defines $($definedSymbols.Count) symbols and none of them is Box3D's, native or managed. Box3D's C# is not in the application either, so the trimmer took it: nothing reachable calls into Box3D, mtouch found no P/Invoke to keep a symbol for, and -dead_strip removed everything -force_load had pulled in. Check that the module initializer this script writes into Box3DUse.cs is still there and still survives trimming." + } + + if ($requested.Count -eq 0) { + throw "The executable carries $($managedSymbols.Count) managed Box3D symbols but not one native one, and mtouch asked the linker to keep no Box3D symbol at all. The C# survived trimming and its P/Invokes did not reach mtouch - check that the package's iOS assembly is the one being used, since it is the only one whose library name is __Internal." + } + + throw "The executable carries $($managedSymbols.Count) managed Box3D symbols but not one native one, even though mtouch asked the linker to keep $($requested.Count). The archive reached the linker and was then dropped - check that ForceLoad is still set in the package's .targets and that the xcframework slice matches the architecture being built." + } + } +} +finally { + Pop-Location +}