From b5c4476bdfdcfd84ea67af2c636567da9a23a713 Mon Sep 17 00:00:00 2001 From: Miguel249 Date: Sun, 9 Aug 2026 15:57:36 -0500 Subject: [PATCH 01/11] Reach the phones, and link where loading is not allowed The packages carried six desktop runtimes and nothing else. They now carry Android and iOS as well, which are not the same kind of addition. Android needs nothing from the consumer and nothing new from the binding. The .so is resolved through the ordinary NuGet runtime-identifier mechanism and packed into the apk, so the existing net8.0 assembly serves it unchanged. Only the 64-bit ABIs ship: Google Play has required 64-bit for years, and Box3D disables NEON on armv7, which has no divide or square root, so armeabi-v7a would be a slower scalar build for devices that cannot be published to anyway. iOS cannot work that way at all. 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 the package carries. The symbols are therefore already in the main image by the time a P/Invoke runs, which is why the binding names __Internal there - and naming it conditionally needs a target framework of its own. That framework is net10.0-ios rather than net8.0-ios because .NET 8's and 9's mobile workloads are out of support and the SDK refuses to build them (NETSDK1202). Every other platform is still served by net8.0. Consequences worth stating rather than discovering: - Building this repository now needs the .NET 10 SDK and the iOS workload, because dotnet build builds every framework a project declares. The test, lint and aot jobs install it; docs.yml instead builds the one framework docfx.json already asked for. - Consumers on iOS need .NET 10. Nobody else does. build-native.ps1 now takes the target from the runtime identifier instead of the host, since the two stopped being the same thing, and gives each target 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. It finds the NDK and the SDK's bundled CMake and Ninja on its own, and strips the Android output, which takes it from about 6 MB to under 900 KB per ABI. Both platforms are verified against the packed .nupkg rather than the repository, because the repository cannot show either failure. 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 its executable checked for Box3D's symbols, which is what proves the static archive survived the link. Neither runs a simulation on a device, and the platform table says "in the apk" and "linked" rather than "yes". Two things that would have shipped broken in silence: - pack gathers package files from the outer build of a multi-targeting project, where every framework-specific property reads empty. Conditioning the iOS build files on TargetPlatformIdentifier was true whenever the inner build was inspected by hand and false during the pack that mattered, so they were simply absent from the package. - .gitignore ignores [Bb]uild/, which covered the very directory NuGet's convention puts consumer build files in. The iOS .targets would never have been committed, and the package would have looked complete while no iOS application could link against it. The README and both package descriptions now also state that this project was built with AI assistance. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 199 +++++++++++- .github/workflows/docs.yml | 7 +- .github/workflows/release.yml | 106 ++++++- .gitignore | 7 + CHANGELOG.md | 44 +++ Directory.Build.props | 14 + Directory.Build.targets | 49 +++ README.md | 98 +++++- docs/architecture.md | 12 + docs/getting-started.md | 15 + src/Box3D.NET.Native/Box3D.NET.Native.csproj | 49 ++- src/Box3D.NET.Native/Interop.cs | 18 +- .../build/Box3D.NET.Native.targets | 51 ++++ src/Box3D.NET/Box3D.NET.csproj | 18 +- tools/build-native.ps1 | 286 +++++++++++++++--- tools/create-xcframework.ps1 | 114 +++++++ tools/verify-package-mobile.ps1 | 233 ++++++++++++++ 17 files changed, 1268 insertions(+), 52 deletions(-) create mode 100644 src/Box3D.NET.Native/build/Box3D.NET.Native.targets create mode 100644 tools/create-xcframework.ps1 create mode 100644 tools/verify-package-mobile.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cfbfe5..7cc74d2 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. @@ -77,11 +122,19 @@ jobs: steps: - uses: actions/checkout@v4 + # .NET 10 and the iOS workload are needed to build the solution at all, + # not to test iOS. The packable projects multi-target net8.0 and + # net10.0-ios, and a plain "dotnet build" builds every framework a project + # declares; without the workload the build fails before a single test runs. - uses: actions/setup-dotnet@v4 with: dotnet-version: | 8.0.x 9.0.x + 10.0.x + + - name: Install the iOS workload + run: dotnet workload install ios --skip-sign-check - uses: actions/download-artifact@v4 with: @@ -131,9 +184,16 @@ jobs: with: submodules: recursive + # As in the test job: the solution declares an iOS framework, so building + # or formatting it needs the workload that understands one. - 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 # The quality gates were once disabled by a condition that silently # evaluated to false, which cost nothing at build time and quietly stopped @@ -224,9 +284,17 @@ jobs: steps: - uses: actions/checkout@v4 + # The samples are net8.0 and only ever resolve the net8.0 side of the + # packable projects, but they reference them, and MSBuild evaluates a + # referenced project in full before deciding which framework it wants. - 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: Install the native toolchain run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev @@ -253,17 +321,29 @@ jobs: # native library inside, and keeps them as an artifact for inspection. pack: name: pack - needs: [native, test, lint] + needs: [native, apple, test, lint] runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 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 on + # macOS; 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 +351,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,13 +381,37 @@ 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.' + } + - name: Pack run: dotnet pack --configuration Release --output ./artifacts/packages @@ -320,6 +430,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 +468,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 +615,51 @@ 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 } + - { os: macos-14, platform: iOS, workload: ios } + + steps: + - uses: actions/checkout@v4 + + - 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..62b6fef 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,56 @@ 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 + publish: name: publish - needs: native + needs: [native, apple] runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 25 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 +151,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,13 +172,36 @@ 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 @@ -151,6 +222,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 +254,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..3cb6775 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,50 @@ 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. + + 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 + its executable checked for Box3D's symbols, which is what proves the static + archive survived the link. 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..2d91442 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -10,6 +10,20 @@ net8.0 + + + net10.0-ios26.0 + latest enable disable diff --git a/Directory.Build.targets b/Directory.Build.targets index adba54c..f701ca6 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..b3e9feb 100644 --- a/src/Box3D.NET.Native/Box3D.NET.Native.csproj +++ b/src/Box3D.NET.Native/Box3D.NET.Native.csproj @@ -8,13 +8,60 @@ --> + + + + + 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..a06cec3 100644 --- a/src/Box3D.NET/Box3D.NET.csproj +++ b/src/Box3D.NET/Box3D.NET.csproj @@ -7,11 +7,27 @@ --> + + + + + 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..1f048c1 --- /dev/null +++ b/tools/create-xcframework.ps1 @@ -0,0 +1,114 @@ +#!/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 { $_ } + +if ($simulatorSlices.Count -gt 1) { + $simulator = Join-Path $OutputDir 'libbox3d-simulator.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 ($simulator -and $simulator -like "*libbox3d-simulator.a") { + Remove-Item -Force $simulator +} + +# 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..20078e8 --- /dev/null +++ b/tools/verify-package-mobile.ps1 @@ -0,0 +1,233 @@ +#!/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 + +# 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. + # + # Not for the sake of running it - nothing here runs - but so that the + # assembly is genuinely referenced. An unused package reference is a + # candidate for the linker to drop, and a check that passes only because + # nothing was trimmed is not the check anyone wants. + @' +// SPDX-License-Identifier: MIT +using System.Numerics; +using Box3D; + +internal static class Box3DUse +{ + 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 + + $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') + } + + 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. + # + # The symbols are only here if the static archive survived the link, + # which is what ForceLoad in the package's .targets is for. Without it + # the linker drops every object file, because nothing refers to them + # until the P/Invoke resolves at run time - far too late to matter. + $symbols = & nm $executable 2>$null | Select-String -Pattern '_b3(CreateWorld|World_Step|CreateBody)' -SimpleMatch:$false + + if (-not $symbols) { + throw "No Box3D symbols found in $executable. The static archive was not linked into the application; check ForceLoad and the xcframework in the package." + } + + $symbols | Select-Object -First 5 | ForEach-Object { Write-Host " $($_.Line.Trim())" } + + Write-Host "`nThe iOS application has Box3D linked into its executable." + } +} +finally { + Pop-Location +} From 85974272a7b433b0cccb4daac485bf9e2934f6af Mon Sep 17 00:00:00 2001 From: Miguel249 Date: Sun, 9 Aug 2026 16:02:01 -0500 Subject: [PATCH 02/11] Stop requiring a workload that Linux cannot install The iOS target framework was added to the packable projects unconditionally, which quietly made building this repository depend on the .NET 10 SDK and the iOS workload - and there is no Linux host pack for Apple's toolchain, so "dotnet workload install ios" does not install something limited there, it fails. Every ubuntu job in CI failed on it, starting with lint. The framework is now opt-in through Box3DTargetApple, off by default. Building, testing and formatting are net8.0 with the .NET 8 SDK, exactly as before. The two jobs that produce packages turn it on and run on macOS, where the workload exists; the iOS half is still verified end to end, by installing the resulting package into a real iOS application and checking Box3D is linked into it. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 50 ++++++++------------ .github/workflows/release.yml | 7 ++- CHANGELOG.md | 7 +++ Directory.Build.props | 19 ++++++++ Directory.Build.targets | 2 +- README.md | 14 ++++-- src/Box3D.NET.Native/Box3D.NET.Native.csproj | 3 +- src/Box3D.NET/Box3D.NET.csproj | 3 +- 8 files changed, 66 insertions(+), 39 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7cc74d2..f7c0851 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,19 +122,11 @@ jobs: steps: - uses: actions/checkout@v4 - # .NET 10 and the iOS workload are needed to build the solution at all, - # not to test iOS. The packable projects multi-target net8.0 and - # net10.0-ios, and a plain "dotnet build" builds every framework a project - # declares; without the workload the build fails before a single test runs. - uses: actions/setup-dotnet@v4 with: dotnet-version: | 8.0.x 9.0.x - 10.0.x - - - name: Install the iOS workload - run: dotnet workload install ios --skip-sign-check - uses: actions/download-artifact@v4 with: @@ -184,16 +176,9 @@ jobs: with: submodules: recursive - # As in the test job: the solution declares an iOS framework, so building - # or formatting it needs the workload that understands one. - uses: actions/setup-dotnet@v4 with: - dotnet-version: | - 8.0.x - 10.0.x - - - name: Install the iOS workload - run: dotnet workload install ios --skip-sign-check + dotnet-version: 8.0.x # The quality gates were once disabled by a condition that silently # evaluated to false, which cost nothing at build time and quietly stopped @@ -284,17 +269,9 @@ jobs: steps: - uses: actions/checkout@v4 - # The samples are net8.0 and only ever resolve the net8.0 side of the - # packable projects, but they reference them, and MSBuild evaluates a - # referenced project in full before deciding which framework it wants. - uses: actions/setup-dotnet@v4 with: - dotnet-version: | - 8.0.x - 10.0.x - - - name: Install the iOS workload - run: dotnet workload install ios --skip-sign-check + dotnet-version: 8.0.x - name: Install the native toolchain run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev @@ -319,11 +296,16 @@ 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, apple, test, lint] - runs-on: ubuntu-latest - timeout-minutes: 20 + runs-on: macos-14 + timeout-minutes: 25 steps: - uses: actions/checkout@v4 with: @@ -340,8 +322,8 @@ jobs: 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 on - # macOS; this produces the library half. + # 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 @@ -412,8 +394,16 @@ jobs: 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62b6fef..a8dba15 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,11 +108,13 @@ jobs: 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, apple] - runs-on: ubuntu-latest - timeout-minutes: 25 + runs-on: macos-14 + timeout-minutes: 30 steps: - uses: actions/checkout@v4 with: @@ -208,6 +210,7 @@ jobs: --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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cb6775..7f125a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 diff --git a/Directory.Build.props b/Directory.Build.props index 2d91442..fe02413 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -24,6 +24,25 @@ --> net10.0-ios26.0 + + false + latest enable disable diff --git a/Directory.Build.targets b/Directory.Build.targets index f701ca6..87b0955 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -132,7 +132,7 @@ check short of opening the .nupkg passed. Box3DAppleTargetFramework is a plain property from Directory.Build.props, which the outer build can see. --> - + - net8.0;$(Box3DAppleTargetFramework) + net8.0 + net8.0;$(Box3DAppleTargetFramework) Box3D.NET.Native Box3D.Native diff --git a/src/Box3D.NET/Box3D.NET.csproj b/src/Box3D.NET/Box3D.NET.csproj index a06cec3..71dee09 100644 --- a/src/Box3D.NET/Box3D.NET.csproj +++ b/src/Box3D.NET/Box3D.NET.csproj @@ -21,7 +21,8 @@ would get the net8.0 binding, which looks for a library file that Apple does not allow the application to contain. --> - net8.0;$(Box3DAppleTargetFramework) + net8.0 + net8.0;$(Box3DAppleTargetFramework) Box3D.NET Box3D From e1e646b19821614b1aeeee409c9717acac04271a Mon Sep 17 00:00:00 2001 From: Miguel249 Date: Sun, 9 Aug 2026 16:10:41 -0500 Subject: [PATCH 03/11] Give both xcframework variants the same archive name xcodebuild copies each -library argument into the framework under the name it arrived with, so the merged simulator archive landed as libbox3d-simulator.a next to the device's libbox3d.a. The framework is valid either way and links either way, but the two variants no longer answer to the same file name, and anything matching on it sees one and not the other - the package inspection did exactly that and failed, which is what it is for. The merge now happens in a directory of its own so the output can keep the name libbox3d.a, and that directory is removed afterwards rather than a single file whose name the script had to know. Co-Authored-By: Claude Opus 5 (1M context) --- tools/create-xcframework.ps1 | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tools/create-xcframework.ps1 b/tools/create-xcframework.ps1 index 1f048c1..286844e 100644 --- a/tools/create-xcframework.ps1 +++ b/tools/create-xcframework.ps1 @@ -78,8 +78,18 @@ New-Item -ItemType Directory -Force $OutputDir | Out-Null $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) { - $simulator = Join-Path $OutputDir 'libbox3d-simulator.a' + 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" } @@ -100,8 +110,8 @@ 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 ($simulator -and $simulator -like "*libbox3d-simulator.a") { - Remove-Item -Force $simulator +if (Test-Path $merged) { + Remove-Item -Recurse -Force $merged } # Report what the framework actually covers rather than what was asked for, From c0f5cedcada167366d470edd8877f27139b16628 Mon Sep 17 00:00:00 2001 From: Miguel249 Date: Sun, 9 Aug 2026 16:19:54 -0500 Subject: [PATCH 04/11] Link the iOS consumer on an image whose Xcode the workload accepts The iOS consumer job built the application on macos-14 and got no further than the SDK's own version gate: .NET for iOS 26.5 requires Xcode 26.6, and macos-14 tops out at 15.4. Nothing about the package was wrong - the restore resolved it for iOS and reported it compatible - so the check never reached the thing it exists to check. It runs on macos-26 now, whose default Xcode is 26.6, and selects the newest Xcode the image carries rather than trusting the default to be it. The job that builds Box3D for iOS stays on macos-14 deliberately. It needs CMake and clang and nothing else, 15.4 has both, and a static archive built against an older SDK links perfectly well into an application built with a newer one. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7c0851..3fb0c87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -625,11 +625,26 @@ jobs: matrix: include: - { os: ubuntu-latest, platform: Android, workload: android } - - { os: macos-14, platform: iOS, workload: ios } + # 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 + # Use the newest Xcode the image carries rather than whatever it defaults + # to, which is not always the newest installed. + - name: Select the newest Xcode + if: matrix.platform == 'iOS' + run: | + newest=$(ls -d /Applications/Xcode_*.app 2>/dev/null | sort -V | tail -1) + if [ -n "$newest" ]; then sudo xcode-select -s "$newest"; fi + xcodebuild -version + - uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.x From 7d0d4184dc775d09fe602dcdf83b899aae5ed909 Mon Sep 17 00:00:00 2001 From: Miguel249 Date: Sun, 9 Aug 2026 16:26:20 -0500 Subject: [PATCH 05/11] Leave the runner's Xcode alone Pointing xcode-select at the newest /Applications/Xcode_*.app was meant to avoid depending on the image's default. It made things worse: those side-by-side installs are not all complete, the one it picked had no macOS SDK under it, and actool failed to run at all - a failure invented entirely by the step meant to prevent one. macos-26 already defaults to Xcode 26.6, which is the version this workload asks for. The step now only reports what it is using, which is what was actually wanted from it. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3fb0c87..f1abaf8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -636,14 +636,14 @@ jobs: steps: - uses: actions/checkout@v4 - # Use the newest Xcode the image carries rather than whatever it defaults - # to, which is not always the newest installed. - - name: Select the newest Xcode + # 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: | - newest=$(ls -d /Applications/Xcode_*.app 2>/dev/null | sort -V | tail -1) - if [ -n "$newest" ]; then sudo xcode-select -s "$newest"; fi - xcodebuild -version + run: xcodebuild -version - uses: actions/setup-dotnet@v4 with: From 9666ff90e3a25b65793ab2debd651dca3f2f59c8 Mon Sep 17 00:00:00 2001 From: Miguel249 Date: Sun, 9 Aug 2026 16:33:42 -0500 Subject: [PATCH 06/11] Stop the mobile consumer inheriting this repository's rules The consumer is meant to be an ordinary application that has never heard of Box3D.NET, and it was not one: it is created under artifacts/, inside the repository, so it inherited Directory.Build.props from the root along with TreatWarningsAsErrors, the analyzers and the documentation gate. The iOS template ships AppDelegate and SceneDelegate, CA1711 objects to both names, and the build failed on this repository's own style rules before it ever got near the package. An empty Directory.Build.props and .targets in the working directory end MSBuild's search upwards. The desktop script avoids the same trap by setting ImportDirectoryBuildProps in a project it writes itself; this one cannot, because the project comes from a workload template. Android passed only because its template happens not to trip a rule. It gets the same treatment rather than the same luck. Co-Authored-By: Claude Opus 5 (1M context) --- tools/verify-package-mobile.ps1 | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tools/verify-package-mobile.ps1 b/tools/verify-package-mobile.ps1 index 20078e8..eea73f3 100644 --- a/tools/verify-package-mobile.ps1 +++ b/tools/verify-package-mobile.ps1 @@ -97,6 +97,31 @@ New-Item -ItemType Directory -Force $WorkDirectory | Out-Null "@ | 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 From e8ac015915eb03a743c960936374846cf1b31007 Mon Sep 17 00:00:00 2001 From: Miguel249 Date: Sun, 9 Aug 2026 16:41:42 -0500 Subject: [PATCH 07/11] Ask the iOS check what actually happened The consumer application built and linked cleanly and then the symbol check failed, which on its own does not say whether Box3D was left out of the link or whether nm simply cannot see it. Guessing between those from a red X is what a second CI run costs, so the check now gathers the answer before it fails. It asks the question two ways. Whether the linker was handed libbox3d.a at all, which is what the package's .targets is responsible for and is visible only in the build log; and whether b3 symbols are in the executable, which is what ForceLoad is responsible for. Either one is proof the archive reached the application, and reporting both says which half to look at. On failure it dumps the symbol count, the bundle contents and every build-log line naming a native reference. That a clean build proves nothing here is the point: a P/Invoke to __Internal is resolved by Mono at run time, so an application whose archive never reached the linker builds, installs and launches exactly like a correct one, and fails on the first physics call - on a device nobody in CI has. Co-Authored-By: Claude Opus 5 (1M context) --- tools/verify-package-mobile.ps1 | 71 ++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/tools/verify-package-mobile.ps1 b/tools/verify-package-mobile.ps1 index eea73f3..886b298 100644 --- a/tools/verify-package-mobile.ps1 +++ b/tools/verify-package-mobile.ps1 @@ -181,6 +181,12 @@ internal static class Box3DUse $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" } @@ -236,21 +242,64 @@ internal static class Box3DUse Write-Host "`nInspecting $executable ($([math]::Round((Get-Item $executable).Length / 1MB, 1)) MB)" - # This is the check that matters on iOS. + # 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 # - # The symbols are only here if the static archive survived the link, - # which is what ForceLoad in the package's .targets is for. Without it - # the linker drops every object file, because nothing refers to them - # until the P/Invoke resolves at run time - far too late to matter. - $symbols = & nm $executable 2>$null | Select-String -Pattern '_b3(CreateWorld|World_Step|CreateBody)' -SimpleMatch:$false - - if (-not $symbols) { - throw "No Box3D symbols found in $executable. The static archive was not linked into the application; check ForceLoad and the xcframework in the package." + # 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) } - $symbols | Select-Object -First 5 | ForEach-Object { Write-Host " $($_.Line.Trim())" } + $symbols = @(& nm $executable 2>$null | Select-String -Pattern '_b3[A-Za-z_]' ) + + Write-Host " archive passed to the linker : $linkerSawArchive" + Write-Host " Box3D symbols in the binary : $($symbols.Count)" + + if (-not $linkerSawArchive -and $symbols.Count -eq 0) { + # Everything worth knowing about why, gathered before failing, so + # that one CI run answers the question instead of starting a guess. + Write-Host "`n--- diagnostics" + + $allSymbols = @(& nm $executable 2>$null) + Write-Host " symbols in the executable: $($allSymbols.Count)" + $allSymbols | Select-Object -First 5 | ForEach-Object { Write-Host " $_" } + + Write-Host " bundle contents:" + Get-ChildItem $app.FullName | ForEach-Object { Write-Host " $($_.Name)" } - Write-Host "`nThe iOS application has Box3D linked into its executable." + if (Test-Path $buildLog) { + Write-Host " build log lines naming the package or a native reference:" + Select-String -Path $buildLog -Pattern 'NativeReference|force_load|Box3D' | + Select-Object -First 15 | + ForEach-Object { Write-Host " $($_.Line.Trim())" } + } + + throw "Box3D never reached the application: the linker was not given libbox3d.a and no b3 symbols are in the executable. The package's iOS .targets did not take effect - check that buildTransitive// matches the consumer's target framework, and that NativeReference names the xcframework correctly." + } + + if ($symbols.Count -gt 0) { + $symbols | Select-Object -First 5 | ForEach-Object { Write-Host " $($_.Line.Trim())" } + Write-Host "`nThe iOS application has Box3D linked into its executable." + } + else { + # The archive was linked but the symbols are not visible to nm, + # which a stripped release binary can legitimately do. Worth saying + # out loud rather than reporting as a clean pass. + Write-Host "`nThe linker was given Box3D's archive, but no b3 symbols are visible in the executable - it is stripped. The link is evidence enough; the symbol check is not available here." + } } } finally { From 2246fa11c52a8c509644186da2da0d260e892907 Mon Sep 17 00:00:00 2001 From: Miguel249 Date: Sun, 9 Aug 2026 16:50:52 -0500 Subject: [PATCH 08/11] Say what the iOS check actually proves It passed by the weaker of its two halves: the linker was handed Box3D's archive, and zero b3 symbols were visible in the executable. The README claimed the symbols were confirmed present, which is not what happened, and a claim this repository cannot back is worse than a narrower one it can. So the wording now matches the check, and the check can now tell the two reasons for an empty symbol list apart. A stripped release binary defines no symbols at all and proves nothing either way, which is the normal case and is reported as such. A binary that kept its symbol table with no b3 in it is something else entirely - the archive reached the linker and was dropped, which is exactly what ForceLoad exists to prevent - and that now fails instead of passing quietly alongside the harmless case. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 6 ++++-- README.md | 16 +++++++++++----- tools/verify-package-mobile.ps1 | 28 +++++++++++++++++++++++----- 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f125a2..4982794 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,8 +40,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 - its executable checked for Box3D's symbols, which is what proves the static - archive survived the link. Neither runs a simulation on a device, and the + 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 diff --git a/README.md b/README.md index 55b259e..3a26f54 100644 --- a/README.md +++ b/README.md @@ -734,11 +734,17 @@ start an application on a phone, so those two words are the honest ceiling: it for `arm64-v8a`. That covers the failure mode specific to Android: an unresolved runtime asset produces a perfectly valid application that dies on its first physics call. -- **linked** — CI builds a real iOS application and confirms Box3D's symbols are - present in the built executable. That is the failure mode specific to iOS: the - library is linked into the application rather than loaded, so a static archive - the linker dropped produces an application that installs and then fails the - same way. +- **linked** — CI builds a real iOS application against the packed `.nupkg` and + confirms the package handed Box3D's archive to the linker. That is the failure + mode specific to iOS: the library is linked into the application rather than + loaded, and a P/Invoke to `__Internal` is resolved by Mono at run time, so an + application the archive never reached builds and launches exactly like a + correct one and fails on its first physics call. + + The stronger check — Box3D's symbols in the finished executable — is attempted + and normally cannot run, because a release build for iOS is stripped and + defines no symbols at all. Where the binary does keep a symbol table and Box3D + is missing from it, that is a failure rather than a shrug. Neither runs a simulation on a device. If you ship Box3D.NET on a phone, test on a phone. diff --git a/tools/verify-package-mobile.ps1 b/tools/verify-package-mobile.ps1 index 886b298..0dd3881 100644 --- a/tools/verify-package-mobile.ps1 +++ b/tools/verify-package-mobile.ps1 @@ -263,9 +263,19 @@ internal static class Box3DUse $linkerSawArchive = [bool] (Select-String -Path $buildLog -Pattern 'libbox3d\.a|box3d\.xcframework' -Quiet) } - $symbols = @(& nm $executable 2>$null | Select-String -Pattern '_b3[A-Za-z_]' ) + $allSymbols = @(& nm $executable 2>$null) + $symbols = @($allSymbols | Select-String -Pattern '_b3[A-Za-z_]') + + # 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, + # while plenty of them and no b3 among them means Box3D is genuinely + # absent. + $definedSymbols = @($allSymbols | Where-Object { $_ -notmatch '^\s*U ' -and $_ -match '^[0-9a-fA-F]+\s' }) Write-Host " archive passed to the linker : $linkerSawArchive" + Write-Host " defined symbols in binary : $($definedSymbols.Count)" Write-Host " Box3D symbols in the binary : $($symbols.Count)" if (-not $linkerSawArchive -and $symbols.Count -eq 0) { @@ -294,11 +304,19 @@ internal static class Box3DUse $symbols | Select-Object -First 5 | ForEach-Object { Write-Host " $($_.Line.Trim())" } Write-Host "`nThe iOS application has Box3D linked into its executable." } + elseif ($definedSymbols.Count -gt 0) { + # The binary kept its symbol table and Box3D is not in it. That is + # not a stripped build hiding the evidence, it is the evidence: + # ForceLoad did not take, and every physics call would fail on the + # device with an entry point that is not there. + throw "The executable defines $($definedSymbols.Count) symbols and not one of them is Box3D's, so the archive reached the linker and was then dropped. That is what ForceLoad in the package's .targets exists to prevent - check that it is still set." + } else { - # The archive was linked but the symbols are not visible to nm, - # which a stripped release binary can legitimately do. Worth saying - # out loud rather than reporting as a clean pass. - Write-Host "`nThe linker was given Box3D's archive, but no b3 symbols are visible in the executable - it is stripped. The link is evidence enough; the symbol check is not available here." + # 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. + 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." } } } From e552a43ae5a7d184f178eb70ce4094199362b224 Mon Sep 17 00:00:00 2001 From: Miguel249 Date: Sun, 9 Aug 2026 22:30:31 -0500 Subject: [PATCH 09/11] Reach Box3D from the iOS entry point, not just from the project The consumer wrote a file that used Box3D and left it unreachable: nothing in the template's AppDelegate refers to Box3DUse, so the trimmer dropped it, mtouch found no __Internal P/Invoke to keep a symbol for, and -dead_strip removed every object -force_load had just pulled out of libbox3d.a. The build succeeded, the archive reached the linker, and the executable came out with 61752 symbols and not one of Box3D's. Insert the call into Main instead, ahead of UIApplication.Main, which is what an application using this package actually looks like. The failure path now reads mtouch-symbols.list before it throws, because "the symbols are missing" has two different causes and they need different fixes: nothing asked for them, or something asked and the link dropped them anyway. Co-Authored-By: Claude Opus 5 (1M context) --- tools/verify-package-mobile.ps1 | 84 +++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 4 deletions(-) diff --git a/tools/verify-package-mobile.ps1 b/tools/verify-package-mobile.ps1 index 0dd3881..11aec6f 100644 --- a/tools/verify-package-mobile.ps1 +++ b/tools/verify-package-mobile.ps1 @@ -171,6 +171,49 @@ internal static class Box3DUse } '@ | Set-Content -Path (Join-Path $WorkDirectory 'Box3DUse.cs') -Encoding utf8 + # On iOS the call has to be reachable from the entry point, not merely + # present in the project. + # + # A file the application never reaches is trimmed away, and on iOS that + # silently undoes the linking this whole check exists to prove. The chain is + # worth spelling out, because every link in it succeeds: + # + # the trimmer drops Box3DUse, since nothing in the template's AppDelegate + # refers to it, and the P/Invokes go with it + # + # mtouch builds the list of symbols to keep from the 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. Calling Fall from Main puts a real reference in the entry point's + # own code path, which is what an application using this package looks like. + if ($Platform -eq 'iOS') { + $entryPoint = Get-ChildItem -Path $WorkDirectory -Filter '*.cs' -File | + Where-Object { (Get-Content -Raw $_.FullName) -match 'UIApplication\.Main\(' } | + Select-Object -First 1 + + if (-not $entryPoint) { + throw 'No file in the iOS template calls UIApplication.Main, so there is nowhere to call Box3D from. The template has changed shape and this script needs to follow it.' + } + + # Inserted ahead of UIApplication.Main 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\()').Replace($source, "`$1global::Box3DUse.Fall();`r`n`r`n`$1`$2", 1) + + if ($patched -eq $source) { + throw "Could not insert the Box3D call into $($entryPoint.Name): the UIApplication.Main call is not on a line of its own. Without it the trimmer drops Box3D and the link check below proves nothing." + } + + Set-Content -Path $entryPoint.FullName -Value $patched -Encoding utf8 + Write-Host "`nCalled Box3D from $($entryPoint.Name), so the entry point reaches it." + } + $buildArgs = @('build', '--configuration', 'Release') # The simulator, because a device build needs a signing identity and a @@ -306,10 +349,43 @@ internal static class Box3DUse } elseif ($definedSymbols.Count -gt 0) { # The binary kept its symbol table and Box3D is not in it. That is - # not a stripped build hiding the evidence, it is the evidence: - # ForceLoad did not take, and every physics call would fail on the - # device with an entry point that is not there. - throw "The executable defines $($definedSymbols.Count) symbols and not one of them is Box3D's, so the archive reached the linker and was then dropped. That is what ForceLoad in the package's .targets exists to prevent - check that it is still set." + # not a stripped build hiding the evidence, it is the evidence: the + # archive was handed to the linker and dropped again, 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 mtouch-symbols.list, the + # file passed to the native link as -exported_symbols_list. mtouch + # fills it from the __Internal P/Invokes left in the application + # after trimming, and -dead_strip keeps only what it 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 ($requested.Count -eq 0) { + throw "The executable defines $($definedSymbols.Count) symbols and not one of them is Box3D's. mtouch asked the linker to keep no Box3D symbol at all, so -dead_strip removed everything -force_load had just pulled in: the application's own code no longer P/Invokes into Box3D by the time mtouch looks. Something reachable from the entry point has to call it - check that the call this script inserts into Main is still there and still survives trimming." + } + + throw "The executable defines $($definedSymbols.Count) symbols and not one of them is Box3D's, 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." } else { # Nothing is visible either way. Release builds for iOS are From 762350260c7e3bc8899e6ba5f2bd81427e2890b1 Mon Sep 17 00:00:00 2001 From: Miguel249 Date: Sun, 9 Aug 2026 22:40:45 -0500 Subject: [PATCH 10/11] Anchor the iOS call where the template cannot move it Editing the entry point was the wrong place to hang this on: no file in the iOS template matches UIApplication.Main( any more, so the guard fired and the job failed on the anchor rather than on the package. The workload writes that file and it has changed spelling once already. A module initializer does not depend on the template's shape. Published with TrimMode=full, Box3DUse survives in the application assembly and Box3D.NET.Native keeps its b3 entry points; with the initializer removed and nothing else changed, Box3DUse is gone and Box3D.NET.Native is not in the publish output at all. That is the trimming this check kept walking into. The entry point edit stays as a second anchor, best effort now, and lists the sources it searched when it finds nowhere to insert. The symbol check at the end is the judge either way. Co-Authored-By: Claude Opus 5 (1M context) --- tools/verify-package-mobile.ps1 | 102 ++++++++++++++++++++------------ 1 file changed, 63 insertions(+), 39 deletions(-) diff --git a/tools/verify-package-mobile.ps1 b/tools/verify-package-mobile.ps1 index 11aec6f..9bd240c 100644 --- a/tools/verify-package-mobile.ps1 +++ b/tools/verify-package-mobile.ps1 @@ -138,19 +138,46 @@ try { & 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. + # 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 so that the - # assembly is genuinely referenced. An unused package reference is a - # candidate for the linker to drop, and a check that passes only because - # nothing was trimmed is not the check anyone wants. + # 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 @@ -171,47 +198,44 @@ internal static class Box3DUse } '@ | Set-Content -Path (Join-Path $WorkDirectory 'Box3DUse.cs') -Encoding utf8 - # On iOS the call has to be reachable from the entry point, not merely - # present in the project. + # A second anchor for the same call, from the template's entry point. # - # A file the application never reaches is trimmed away, and on iOS that - # silently undoes the linking this whole check exists to prove. The chain is - # worth spelling out, because every link in it succeeds: - # - # the trimmer drops Box3DUse, since nothing in the template's AppDelegate - # refers to it, and the P/Invokes go with it - # - # mtouch builds the list of symbols to keep from the 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. Calling Fall from Main puts a real reference in the entry point's - # own code path, which is what an application using this package looks like. + # 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') { - $entryPoint = Get-ChildItem -Path $WorkDirectory -Filter '*.cs' -File | - Where-Object { (Get-Content -Raw $_.FullName) -match 'UIApplication\.Main\(' } | + # 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 - if (-not $entryPoint) { - throw 'No file in the iOS template calls UIApplication.Main, so there is nowhere to call Box3D from. The template has changed shape and this script needs to follow it.' - } - - # Inserted ahead of UIApplication.Main 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\()').Replace($source, "`$1global::Box3DUse.Fall();`r`n`r`n`$1`$2", 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 -eq $source) { - throw "Could not insert the Box3D call into $($entryPoint.Name): the UIApplication.Main call is not on a line of its own. Without it the trimmer drops Box3D and the link check below proves nothing." + 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." + } } - Set-Content -Path $entryPoint.FullName -Value $patched -Encoding utf8 - Write-Host "`nCalled Box3D from $($entryPoint.Name), so the entry point reaches it." + 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') From 13ba148382860a7c4f9e1ef59e7247bca4c3b1f7 Mon Sep 17 00:00:00 2001 From: Miguel249 Date: Sun, 9 Aug 2026 22:54:00 -0500 Subject: [PATCH 11/11] Count the symbols that prove the link, not the ones that look like it The check searched the binary for b3 and found 968 of them, all managed: an AOT-compiled method carries its parameter types in its mangled name, so b3BodyId and b3ShapeDef turn up inside names like _Box3D_NET_Box3D_Body__ctor_Box3D_Native_b3BodyId. Those come from the managed assembly and would be in the executable whether or not libbox3d.a survived the link, which is the one thing this job exists to establish. Count the two apart. A defined symbol whose name starts _b3 came out of the archive; _Box3D_NET_ ones say the trimmer kept Box3D's C#. Passing now needs a native symbol, and the managed count separates the two ways this has failed: no managed symbols means the trimmer took Box3D before mtouch could ask for anything, managed but no native means the archive was dropped at the link. Co-Authored-By: Claude Opus 5 (1M context) --- tools/verify-package-mobile.ps1 | 103 +++++++++++++++++--------------- 1 file changed, 55 insertions(+), 48 deletions(-) diff --git a/tools/verify-package-mobile.ps1 b/tools/verify-package-mobile.ps1 index 9bd240c..b06367d 100644 --- a/tools/verify-package-mobile.ps1 +++ b/tools/verify-package-mobile.ps1 @@ -331,57 +331,67 @@ internal static class Box3DUse } $allSymbols = @(& nm $executable 2>$null) - $symbols = @($allSymbols | Select-String -Pattern '_b3[A-Za-z_]') # 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, - # while plenty of them and no b3 among them means Box3D is genuinely - # absent. + # 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 symbols in the binary : $($symbols.Count)" - - if (-not $linkerSawArchive -and $symbols.Count -eq 0) { - # Everything worth knowing about why, gathered before failing, so - # that one CI run answers the question instead of starting a guess. - Write-Host "`n--- diagnostics" - - $allSymbols = @(& nm $executable 2>$null) - Write-Host " symbols in the executable: $($allSymbols.Count)" - $allSymbols | Select-Object -First 5 | ForEach-Object { Write-Host " $_" } - - Write-Host " bundle contents:" - Get-ChildItem $app.FullName | ForEach-Object { Write-Host " $($_.Name)" } + Write-Host " Box3D native symbols : $($nativeSymbols.Count)" + Write-Host " Box3D managed AOT symbols : $($managedSymbols.Count)" - if (Test-Path $buildLog) { - Write-Host " build log lines naming the package or a native reference:" - Select-String -Path $buildLog -Pattern 'NativeReference|force_load|Box3D' | - Select-Object -First 15 | - ForEach-Object { Write-Host " $($_.Line.Trim())" } - } - - throw "Box3D never reached the application: the linker was not given libbox3d.a and no b3 symbols are in the executable. The package's iOS .targets did not take effect - check that buildTransitive// matches the consumer's target framework, and that NativeReference names the xcframework correctly." + 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.' + } - if ($symbols.Count -gt 0) { - $symbols | Select-Object -First 5 | ForEach-Object { Write-Host " $($_.Line.Trim())" } - Write-Host "`nThe iOS application has Box3D linked into its executable." + 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." } - elseif ($definedSymbols.Count -gt 0) { - # The binary kept its symbol table and Box3D is not in it. That is - # not a stripped build hiding the evidence, it is the evidence: the - # archive was handed to the linker and dropped again, and every - # physics call would fail on the device with an entry point that is - # not there. + 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 mtouch-symbols.list, the - # file passed to the native link as -exported_symbols_list. mtouch - # fills it from the __Internal P/Invokes left in the application - # after trimming, and -dead_strip keeps only what it names. + # 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 = @() @@ -390,7 +400,7 @@ internal static class Box3DUse if ($symbolList) { $listed = @(Get-Content $symbolList.FullName) - $requested = @($listed | Select-String -Pattern '_b3[A-Za-z_]') + $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())" } } @@ -405,18 +415,15 @@ internal static class Box3DUse 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 defines $($definedSymbols.Count) symbols and not one of them is Box3D's. mtouch asked the linker to keep no Box3D symbol at all, so -dead_strip removed everything -force_load had just pulled in: the application's own code no longer P/Invokes into Box3D by the time mtouch looks. Something reachable from the entry point has to call it - check that the call this script inserts into Main is still there and still survives trimming." + 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 defines $($definedSymbols.Count) symbols and not one of them is Box3D's, 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." - } - else { - # 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. - 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." + 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." } } }