Skip to content

fix: ship a link.xml so IL2CPP stripping cannot empty crash responses - #220

Merged
bobbyg603 merged 2 commits into
mainfrom
fix/link-xml
Aug 20, 2026
Merged

fix: ship a link.xml so IL2CPP stripping cannot empty crash responses#220
bobbyg603 merged 2 commits into
mainfrom
fix/link-xml

Conversation

@bobbyg603

Copy link
Copy Markdown
Member

Closes #157

BugSplatResponse is only ever produced by JsonUtility.FromJson (Runtime/Reporter/DotNetStandardExceptionReporter.cs:289 and Runtime/Client/WebGLExceptionClient.cs:78). Nothing in the IL references its fields or its parameterless constructor, so Medium/High managed stripping is free to remove them. There is no build error and no runtime exception, just a BugSplatResponse with a null infoUrl and a crashId of 0 handed to every Post callback, on exactly the IL2CPP configuration the README recommends.

Where the link.xml had to go, and why there is a build callback

A link.xml at the package root would have been silently ignored. Unity's manual says it plainly (Preserving code):

The link.xml file must be present in the Assets folder or a subdirectory of the Assets folder in your project. [...] You can't include a link.xml file in a package, but you can reference package assemblies from non-package link.xml files.

The editor source agrees — UnityCsReference Editor/Mono/BuildPipeline/AssemblyStripper.cs collects user descriptors with exactly one glob, and Packages/ is not in it:

internal static IEnumerable<NPath> GetUserBlacklistFiles()
{
    return Directory.GetFiles("Assets", "link.xml", SearchOption.AllDirectories)
        .Select(s => Path.Combine(Directory.GetCurrentDirectory(), s))
        .ToNPaths();
}

The same method adds ProcessBuildPipelineGenerateAdditionalLinkXmlFiles(args), which calls IUnityLinkerProcessor.GenerateAdditionalLinkXmlFile on every registered processor and keeps whatever paths exist. That is the supported route for a package, so:

  • Runtime/link.xml holds the descriptor, next to the assembly it preserves and reviewable as a plain file.
  • Editor/BugSplatLinkXmlProcessor.cs implements IUnityLinkerProcessor and returns that file's absolute path, resolved through PackageInfo.FindForAssembly so it works from Library/PackageCache, from an embedded package, and from a tarball install alike.
  • If the SDK was copied under Assets/ instead of installed as a package, FindForAssembly returns null and the processor returns null — UnityLinker's own Assets/** glob already finds the file, and Unity filters null/missing paths (.Where(p => p?.FileExists() ?? false)).

What is preserved, and the granularity choice

<assembly fullname="BugSplat.Unity.Runtime">
    <type fullname="BugSplatUnity.Runtime.Reporter.BugSplatResponse" preserve="all"/>
</assembly>

One type. No preserve="all" on an assembly, and no wildcards.

preserve="all" at the type level rather than preserve="fields" because JsonUtility needs the compiler-generated parameterless constructor as well, and a constructor is a method — fields would keep the three fields and let the linker drop the thing that instantiates them. Metadata for the compiled type is exactly status, infoUrl, crashId and .ctor (dumped below), so all preserves nothing beyond what deserialization requires.

I audited the whole Runtime/ tree for other members reachable only by reflection or serialization:

Candidate Decision
BugSplatResponse Preserved. Only ever populated by JsonUtility.FromJson.
BugSplatDotNetStandard.dll (vendored) Not preserved — see below.
BugSplatOptions, BugSplatAttribute, BugSplatManager Not preserved. Unity serialization reaches them, but the build already roots types found in scenes and serialized assets: AssemblyStripper.WriteTypesInScenesBlacklist and WriteSerializedTypesBlacklist generate TypesInScenes.xml / SerializedTypes.xml for precisely this. Repeating them buys nothing.
Native interop Nothing to preserve. Runtime/BugSplat.cs calls out through DllImport and AndroidJavaClass only; there are no [MonoPInvokeCallback] delegates, no AndroidJavaProxy subclasses and no UnitySendMessage targets, i.e. nothing that native code enters by name.
Reflection in the SDK's own code None. No Type.GetType, GetMethod, GetProperty or Activator use anywhere under Runtime/.

Why the vendored DLL is not preserved

The issue suggested preserving BugSplatDotNetStandard.dll; I checked what it actually needs and concluded it does not.

  1. Every entry point the player reaches (BugSplat.Post, CrashPostClient, FormDataParam, …) is invoked by direct calls from DotNetStandardClient / DotNetStandardExceptionReporter, so the linker's static analysis keeps them.
  2. It has no reflection-driven serialization. Its Http/JsonObject.cs parses responses by hand via JsonReaderWriterFactory into an XElement and queries it, and JsonSerializer builds JSON by string concatenation — no POCO mapping, no DataContractJsonSerializer, no System.Reflection. A strings dump of the shipped DLL matches the upstream source (CreateJsonReader, XmlDictionaryReader, XmlDictionaryReaderQuotas; no DataContract, no Activator, no GetMethod/GetProperty/GetField).
  3. preserve="all" on it would pin the OAuth2 and symbol-upload code that only the Editor ever calls into every player build, which is the opposite of what a crash reporter should cost.

If a future version of that DLL starts deserializing into types, this decision has to be revisited — the reasoning is recorded in Runtime/link.xml so the omission reads as deliberate rather than forgotten.

How each name was verified

A link.xml naming an assembly or type that does not exist is silently useless, so every string in it was checked against the built artifact, not just against the source:

  1. XML is well-formed — parsed with three independent parsers: PowerShell [xml], System.Xml.Linq.XDocument.Load, and Python xml.dom.minidom. All three round-trip the document and expose assembly/@fullname and type/@fullname as intended (the explanatory comments do not swallow the elements).
  2. Assembly nameRuntime/BugSplat.Unity.Runtime.asmdef declares "name": "BugSplat.Unity.Runtime", which is also the name CI's CompileCheck asserts (BugSplat.Unity.Runtime.dll).
  3. Type name — namespace BugSplatUnity.Runtime.Reporter (Runtime/Reporter/IExceptionReporter.cs:5), top-level class BugSplatResponse (line 14), so no nested-type / syntax applies.
  4. Both, against a real compiled assembly — I compiled every Runtime/**/*.cs into BugSplat.Unity.Runtime.dll with Roslyn against the assemblies of a local Unity 6000.5.6f1 install plus the vendored DLL, then read the result's metadata directly (PEReader/MetadataReader) and diffed it against the link.xml:
asmdef name            : BugSplat.Unity.Runtime
compiled assembly name : BugSplat.Unity.Runtime
link.xml assembly      : BugSplat.Unity.Runtime -> asmdef match: True, compiled match: True
link.xml type          : BugSplatUnity.Runtime.Reporter.BugSplatResponse -> exists: True
  fields : status (Public) | infoUrl (Public) | crashId (Public)
  methods: .ctor (Public, HideBySig, SpecialName, RTSpecialName)
types matching *BugSplatResponse* : BugSplatUnity.Runtime.Reporter.BugSplatResponse
  1. The processor compiles against the real editor APIEditor/BugSplatLinkXmlProcessor.cs was compiled with Roslyn against Unity 6000.5.6f1's UnityEditor.dll / UnityEngine.dll / UnityEngine.CoreModule.dll (exit 0), which checks UnityEditor.Build.IUnityLinkerProcessor, the GenerateAdditionalLinkXmlFile(BuildReport, UnityLinkerBuildPipelineData) signature, and PackageInfo.FindForAssembly(...).resolvedPath against the shipping API rather than from memory. The interface declaration in UnityCsReference confirms callbackOrder is its only other member (the old OnBeforeRun/OnAfterRun are gone), and BuildPipelineInterfaces instantiates processors via Activator.CreateInstance off TypeCache.GetTypesDerivedFrom<IOrderedCallback>(), which an internal class with the implicit public constructor satisfies.

Not verified here

I cannot run Unity or produce a stripped IL2CPP player in this environment, so what is proven is that the descriptor is well-formed, that its names match the real assembly and type, and that the callback that delivers it compiles against the real editor API. End-to-end proof — an IL2CPP player built at Medium/High stripping that posts a report and gets back a populated infoUrl and non-zero crashId — is deferred to the release smoke test (#200). The most useful thing to watch for there is the stripping level: at Minimal the bug does not reproduce, so the smoke test needs Medium or High to be meaningful.

🤖 Generated with Claude Code

BugSplatResponse is only ever produced by JsonUtility.FromJson, so nothing in
the IL references its fields or its parameterless constructor. Under Medium or
High managed stripping the linker is free to remove them, and the failure is
silent: callbacks still receive a BugSplatResponse, only with a null infoUrl
and a crashId of 0.

UnityLinker globs Assets/**/link.xml and nothing else (UnityCsReference
AssemblyStripper.GetUserBlacklistFiles), so a link.xml shipped inside a UPM
package is never read. BugSplatLinkXmlProcessor implements
IUnityLinkerProcessor.GenerateAdditionalLinkXmlFile to name the packaged file,
which is the mechanism Unity points packages at. When the sources are copied
under Assets/ rather than installed as a package the PackageInfo lookup returns
null and UnityLinker's own glob already covers the file.

The vendored BugSplatDotNetStandard.dll is deliberately not preserved: every
entry point the player uses is called directly, and its JSON handling is manual
(JsonReaderWriterFactory/XElement) rather than reflective, so there is nothing
for the linker to miss. Unity's own scene-type and serialized-type roots
already cover BugSplatOptions, BugSplatAttribute and BugSplatManager.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 11, 2026 21:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses an IL2CPP managed-stripping issue where BugSplatResponse fields (and its implicit parameterless constructor) can be stripped because they’re only accessed via JsonUtility.FromJson, resulting in empty/null crash response data at runtime. It adds a package-hosted link.xml plus an Editor callback to ensure UnityLinker consumes that descriptor even though it doesn’t scan Packages/** for link.xml.

Changes:

  • Add Runtime/link.xml to preserve BugSplatUnity.Runtime.Reporter.BugSplatResponse from managed stripping.
  • Add Editor/BugSplatLinkXmlProcessor.cs implementing IUnityLinkerProcessor to supply the package link.xml to UnityLinker during builds.
  • Add Unity .meta files for the new assets/scripts.

Reviewed changes

Copilot reviewed 2 out of 4 changed files in this pull request and generated 1 comment.

File Description
Runtime/link.xml Adds UnityLinker descriptor preserving BugSplatResponse needed for JsonUtility deserialization under stripping.
Runtime/link.xml.meta Unity asset metadata for Runtime/link.xml.
Editor/BugSplatLinkXmlProcessor.cs UnityLinker processor that provides the package link.xml path during build.
Editor/BugSplatLinkXmlProcessor.cs.meta Unity asset metadata for the new editor script.
Files not reviewed (2)
  • Editor/BugSplatLinkXmlProcessor.cs.meta: Generated file
  • Runtime/link.xml.meta: Generated file

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +22 to +38
var package = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(BugSplatLinkXmlProcessor).Assembly);
if (package == null)
{
// Sources were copied under Assets/ rather than installed as a package, which puts
// link.xml somewhere UnityLinker already looks.
return null;
}

var linkXml = Path.Combine(package.resolvedPath, "Runtime", "link.xml");
if (!File.Exists(linkXml))
{
Debug.LogWarning($"BugSplat warning: {linkXml} is missing, managed stripping may leave crash report responses empty");
return null;
}

return linkXml;
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8fd87f1 — when FindForAssembly returns null the processor now checks Packages/com.bugsplat.unity/Runtime/link.xml (the same fallback root PostBuild.cs uses) and hands that to UnityLinker if it exists; only when the fallback is also absent does it conclude the sources live under Assets/ and return null. The missing-file warning stays limited to the confirmed package-install path so Assets-based installs don't get a false warning.

PostBuild.cs already treats a null PackageInfo.FindForAssembly result
as possible for package installs and falls back to
Packages/com.bugsplat.unity; the link.xml processor instead concluded
the sources were copied under Assets/ and silently skipped the
descriptor. Try the package root first and only treat the install as
Assets-based when no link.xml exists there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 19, 2026 22:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 4 changed files in this pull request and generated no new comments.

Files not reviewed (2)
  • Editor/BugSplatLinkXmlProcessor.cs.meta: Generated file
  • Runtime/link.xml.meta: Generated file

@bobbyg603
bobbyg603 merged commit 626ba12 into main Aug 20, 2026
14 checks passed
@bobbyg603
bobbyg603 deleted the fix/link-xml branch August 20, 2026 00:03
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.

H3: Ship a link.xml — IL2CPP stripping silently breaks response parsing

3 participants