Skip to content

Reach the phones, and link where loading is not allowed - #6

Merged
Miguel249 merged 11 commits into
mainfrom
platforms/mobile
Aug 10, 2026
Merged

Reach the phones, and link where loading is not allowed#6
Miguel249 merged 11 commits into
mainfrom
platforms/mobile

Conversation

@Miguel249

Copy link
Copy Markdown
Owner

Adds Android and iOS to the packages, which were desktop-only.

Android needs 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 arm64-v8a and x86_64 ship — Play has required 64-bit for years, and Box3D falls back to a scalar path on armv7.

iOS cannot work that way. Apple does not allow an application to load a dynamic library that is not a signed framework, so Box3D ships as a static archive in an xcframework and is linked in by a .targets file the package carries. The binding names __Internal there, which needs a target framework of its own — net10.0-ios, because .NET 8's and 9's mobile workloads are out of support and the SDK refuses net8.0-ios outright (NETSDK1202).

What this costs

  • Building this repository now needs the .NET 10 SDK and the ios workload, since dotnet build builds every declared framework. The test, lint and aot jobs install it; docs.yml instead builds the single framework docfx.json already asked for.
  • Consumers on iOS need .NET 10. Nobody else does.

Verification

Against the packed .nupkg, not the repository, because the repository cannot show either failure mode:

  • a real Android application is built and its .apk opened to confirm libbox3d.so is inside it;
  • 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 silent failures found on the way

  • pack gathers package files from the outer build of a multi-targeting project, where framework-specific properties read 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 — the files were simply missing from the package.
  • .gitignore ignores [Bb]uild/, which covers exactly the 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.

Also states in the README and in both package descriptions that this project was built with AI assistance.

🤖 Generated with Claude Code

Miguel249 and others added 11 commits August 9, 2026 15:57
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@Miguel249
Miguel249 merged commit 5ee3d03 into main Aug 10, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant