Skip to content

[TrimmableTypeMap] Improve array handling#11238

Open
simonrozsival wants to merge 18 commits intomainfrom
dev/simonrozsival/trimmable-array-typemap
Open

[TrimmableTypeMap] Improve array handling#11238
simonrozsival wants to merge 18 commits intomainfrom
dev/simonrozsival/trimmable-array-typemap

Conversation

@simonrozsival
Copy link
Copy Markdown
Member

@simonrozsival simonrozsival commented Apr 28, 2026

Summary

Replaces the per-T JavaPeerContainerFactory<T>.CreateArray factory with a runtime fork that uses the .NET runtime type loader on CoreCLR/Mono and a per-rank trimmable typemap on NativeAOT. Eliminates the per-peer array-creation IL bloat that JavaPeerContainerFactory<T> was contributing — without the trim regressions we evaluated through several alternative designs (see Considered alternatives at the end).

Tracking: #11234 Phase 2 (arrays-only — container types JavaList<T> / JavaCollection<T> / JavaDictionary<K, V> stay untouched and have their own follow-up PR).

Runtime fork

JNIEnv.ArrayCreateInstance branches on RuntimeFeature.IsDynamicCodeSupported:

if (RuntimeFeature.TrimmableTypeMap) {
    if (System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported) {
        // CoreCLR / Mono — runtime type loader can construct any T[] dynamically.
        // No typemap roundtrip; supports unlimited array rank.
        return Array.CreateInstance (elementType, length);
    }
    // NativeAOT — resolve via per-rank trimmable typemap +
    // AOT-safe Array.CreateInstanceFromArrayType.
    if (TrimmableTypeMap.Instance.TryGetArrayType (elementType, out var arrayType)) {
        return Array.CreateInstanceFromArrayType (arrayType, length);
    }
    throw new NotSupportedException ("...");
}
// legacy non-trimmable fallback unchanged

Why fork? CoreCLR's runtime type loader can construct any T[] from its element-type metadata at runtime, so going through the typemap is unnecessary work and trim warnings. NativeAOT cannot (value-type arrays each need their own codegen, and our marshaling call sites are opaque to the trimmer), so the typemap is the only AOT-safe route.

Generator changes

For every non-aliased, non-generic peer (excluding JNI primitive-keyword keys), the generator emits three speculative TypeMap entries keyed by the element JNI name and anchored to per-typemap-assembly rank sentinel TypeDefs:

// Inside each per-assembly typemap dll, when $(PublishAot) == true:
internal sealed class __ArrayMapRank1 { }
internal sealed class __ArrayMapRank2 { }
internal sealed class __ArrayMapRank3 { }

[assembly: TypeMap<__ArrayMapRank1>("java/lang/String", typeof(string[]),     typeof(string[]))]
[assembly: TypeMap<__ArrayMapRank2>("java/lang/String", typeof(string[][]),   typeof(string[][]))]
[assembly: TypeMap<__ArrayMapRank3>("java/lang/String", typeof(string[][][]), typeof(string[][][]))]
  • Trim target = closed array type so ILC's per-shape conditional drops the entry when the array shape is never constructed (validated at ~/Projects/playground/TestTypeMapArrays with disjoint type sets — works for both ref- and value-type elements).
  • Element-only JNI keys — no "[" + "L" + jni + ";" runtime concat.
  • Per-rank groups — rank is a switch dispatch at runtime, not part of the key. The __ArrayMapRank{N} sentinels mirror the existing __TypeMapAnchor pattern (per-assembly, like generic anchor types).
  • Conditional emission gated on $(PublishAot) == true. Saves attribute metadata on CoreCLR-only builds where the runtime fork bypasses the typemap entirely.
  • Skip rules: open-generic peers (typeof(T<>[]) invalid), JNI primitive-keyword keys (handled by the legacy primitive-array path), alias groups (would produce duplicate keys; deferred until alias-aware design).

Runtime API

ITypeMapWithAliasing gains:

bool TryGetArrayType (string jniElementTypeName, int rank, [NotNullWhen (true)] out Type? arrayType);

SingleUniverseTypeMap carries three nullable IReadOnlyDictionary<string, Type>? fields (rank 1, 2, 3); AggregateTypeMap does first-wins iteration. TrimmableTypeMap.Initialize gains 5-arg overloads (single + aggregate) accepting the per-rank dicts; the existing 2-arg overloads stay as wrappers passing null per-rank dicts.

TrimmableTypeMap.TryGetArrayType(Type elementType, out Type? arrayType) walks down elementType.IsArray / GetElementType() to find the leaf type and the element array depth, resolves the leaf JNI element name (primitive static dict OR TryGetJniNameForManagedType wrapped), and delegates the (elementJni, rank=elementDepth+1) lookup to _typeMap.TryGetArrayType.

Generator IL emission

RootTypeMapAssemblyGenerator.EmitTypeMapLoader branches on emitArrayEntries. When true, the generated TypeMapLoader.Initialize IL collects per-rank dicts from each per-assembly typemap (5 IReadOnlyDictionary<...>[] arrays total — typeMaps, proxyMaps, arrayMapsRank1/2/3) and passes them to the 5-arg TrimmableTypeMap.Initialize. The aggregate (Debug) path is fully implemented; the merged-universe (Release) path throws at generation time with a clear message — wiring the shared-universe array sentinels is a small follow-up.

What's deleted

  • JavaPeerContainerFactory<T>.CreateArray and CreateHigherRankArray.
  • The abstract base method on JavaPeerContainerFactory.

Other factory methods (CreateList, CreateCollection, CreateDictionary*) stay — those will be addressed in the follow-up Phase 2-collections PR.

Files changed

File Notes
Generator/Model/TypeMapAssemblyData.cs New TypeMapAttributeData.AnchorRank (1-based per-rank anchor) and TypeMapAssemblyData.RankSentinels (sentinel TypeDef names).
Generator/TypeMapAssemblyEmitter.cs EmitRankSentinels mirrors EmitAnchorType; per-anchor TypeMap<TGroup> 3-arg ctor caching via GetOrAddTypeMapAttr3ArgCtorRef.
Generator/ModelBuilder.cs New bool emitArrayEntries parameter on Build; per-rank trio emitted via new EmitArrayEntries (skip rules: generics, primitive keywords, aliases).
Generator/RootTypeMapAssemblyGenerator.cs New bool emitArrayEntries parameter; aggregate path emits per-rank dict arrays in TypeMapLoader.Initialize; new 5-arg Initialize member refs.
TrimmableTypeMapGenerator.cs Execute and GenerateTypeMapAssemblies propagate emitArrayEntries.
Generator/TypeMapAssemblyGenerator.cs Generate propagates emitArrayEntries.
Tasks/GenerateTrimmableTypeMap.cs New EmitArrayEntries MSBuild task property.
targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets Sets EmitArrayEntries="$(PublishAot)" on the task invocation.
Microsoft.Android.Runtime/ITypeMapWithAliasing.cs TryGetArrayType interface method.
Microsoft.Android.Runtime/SingleUniverseTypeMap.cs Per-rank dict ctor + TryGetArrayType impl.
Microsoft.Android.Runtime/AggregateTypeMap.cs First-wins TryGetArrayType impl.
Microsoft.Android.Runtime/TrimmableTypeMap.cs New 5-arg Initialize overloads + TryGetArrayType(Type, out Type?) + TryGetPrimitiveJniName helper.
Android.Runtime/JNIEnv.cs ArrayCreateInstance runtime fork.
Java.Interop/JavaPeerContainerFactory.cs CreateArray / CreateHigherRankArray deleted; class doc updated.
tests/.../TypeMapModelBuilderTests.cs 14 new generator unit tests covering default-off behavior, sentinel emission, per-rank trio, element-only key, closed-array trim target, conditional-only entries, skip rules (open generic, alias group, primitive keywords), multi-peer isolation, and PE blob round-trip.

Validation

  • Generator unit tests: 445 / 445 pass (425 baseline + 20 new).
  • Trimmable + CoreCLR RunTestApp lane on emulator (-p:_AndroidTypeMapImplementation=trimmable -p:UseMonoRuntime=false): 917 total, 0 errors, 3 failures (the 3 are pre-existing TryGetJniNameForManagedType_* from [TrimmableTypeMap] JavaCast/JavaAs + container support #11225, called out as out-of-scope there). No regression.
  • NativeAOT path with array TypeMap entries: validated with the standalone repro at ~/Projects/playground/TestTypeMapArrays against .NET 11 nightly 11.0.100-preview.5.26228.123 (which bundles [ILLink/ILCompiler] Fix crash when array types are used as TypeMap trim targets runtime#126380's TypeMapHandler.UnwrapToResolvableType fix). The dotnet/android end-to-end NativeAOT lane needs the SDK to ship a build with #126380 — currently in our SDK we have 11.0.0-preview.4.26215.121 which crashes ILLink before reaching the new code.

SDK gating

The NativeAOT array-emission path requires dotnet/runtime#126380 (TypeMapHandler.UnwrapToResolvableType, merged 2026-04-20, ships in .NET 11 nightly preview.5+). Until dotnet/android picks up an SDK with that fix:

  • CoreCLR / Mono — works today. The runtime fork hits Array.CreateInstance directly; the typemap isn't consulted.
  • NativeAOT with _AndroidTypeMapImplementation=trimmable — would crash in ILLink's TypeMapHandler.RecordTypeMapEntry on the [assembly: TypeMap<__ArrayMapRank{N}>(...)] array entries. Not in any current dotnet/android CI lane (the typical NativeAOT lanes use Mono runtime or don't enable trimmable typemap), so the PR can land without blocking on the SDK bump. Once the SDK bumps, the path becomes live with no further changes.

Considered alternatives

Documented in detail in #11234. Summary:

  • Proxy map (TypeMapAssociation<G>(typeof(T), typeof(T[]))): cleaner Type → Type lookup, but ProxyTypeMapNode.GetConditionalStaticDependencies conditions on MaximallyConstructableType(key) (the element), so T[] survives whenever T is reachable — losing per-shape trimming. Validated on disjoint-set NativeAOT tests.
  • Single composite-key external map ("[L<jni>;", "[[L<jni>;"): same trim behavior as per-rank groups but pays a string.Concat at every JNIEnv.ArrayCreateInstance call. Per-rank groups eliminate that cost.
  • Scanner-based emission: tighter trim than speculative emission, but requires a metadata signature walker. Layerable on top of this PR as a follow-up if app-size measurement shows speculative emission is too costly.

Out of scope

  • Container types (JavaList<T> / JavaCollection<T> / JavaDictionary<K, V>) and the corresponding JavaPeerContainerFactory<T>.Create* methods.
  • JavaPeerProxy<T>.GetContainerFactory() virtual / override (deletion follows in the Phase 3 PR).
  • TrimmableTypeMap.GetContainerFactory(Type).
  • Removing [DAM(Constructors)] annotations ([TrimmableTypeMap] Address all trimming and AOT warnings #10794).
  • Alias-aware array emission.
  • Merged-universe (Release) array emission — generator throws at generation time today; small follow-up to wire the shared-universe loader.

simonrozsival and others added 3 commits April 28, 2026 13:21
Improve CreatePeer under the trimmable typemap to match legacy
JavaCast/JavaAs contracts:

- Bad-cast disambiguation: distinguish incompatible Java types
  (return null → InvalidCastException) from missing typemap entries
  (ArgumentException) and generator gaps (NotSupportedException).

- Closed-generic activation: when the proxy targets an open generic
  (e.g. JavaList<>), activate the closed targetType (e.g. JavaList<int>)
  via reflection using the (IntPtr, JniHandleOwnership) ctor. The
  [DynamicallyAccessedMembers(Constructors)] annotation on targetType
  guarantees the trimmer preserves the ctor metadata.

- Type resolution: map IJavaPeerable/object/Exception to concrete peer
  types before proxy lookup, mirroring the legacy GetPeerType behavior.

- TargetTypeMatches: restructure so open-generic proxies match only
  closed instantiations of their definition.

- FindClass safety: catch ClassNotFoundException in
  TryGetProxyFromTargetType for types not present in the APK.

- Don't synthesize activation from inherited ctors: match legacy
  GetConstructor() which doesn't find inherited constructors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ranks

- Resolve activation ctor on invoker types for interface peers, so the
  generator picks the correct ctor signature (XA vs JI style).

- Skip JNI keyword types (Z, B, C, S, I, J, F, D) in the scanner.
  These single-letter JNI names collided with primitive type handling
  in JniRuntime.JniTypeManager.

- Skip all ArrayRank>0 types (JavaBooleanArray, JavaArray<>, etc.) —
  they were incorrectly added as aliases for java/lang/Object, causing
  alias resolution to select the open-generic JavaArray<> proxy.

- Don't synthesize activation from inherited ctors in the generator,
  matching legacy Type.GetConstructor() behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add trimmable-typemap variant of GetThis.java that uses
mono.android.Runtime.register instead of ManagedPeer.registerNativeMembers.
Java.Interop-Tests.targets swaps variants based on
$(_AndroidTypeMapImplementation).

Re-enabled tests:
- JavaCast_BadInterfaceCast (bad-cast disambiguation)
- JavaCast_BaseToGenericWrapper (closed-generic activation)
- JavaCast_CheckForManagedSubclasses (bad-cast disambiguation)
- JavaCast_InvalidTypeCastThrows (bad-cast disambiguation)
- JavaAs_Exceptions (inherited ctor fix)
- DisposeAccessesThis (trimmable GetThis.java)
- CreateGenericValue (ArrayRank scanner fix)

Updated remaining exclusion comments with accurate root-cause
descriptions. Removed resolved TODO comments from test files.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@simonrozsival simonrozsival changed the base branch from main to dev/simonrozsival/trimmable-typemap-javacast April 28, 2026 15:19
@simonrozsival simonrozsival changed the title [TrimmableTypeMap] Replace per-T array factory with TypeMap entries [TrimmableTypeMap] Add array-typemap scaffolding (blocked on ILLink fix) Apr 28, 2026
@simonrozsival simonrozsival added copilot `copilot-cli` or other AIs were used to author this trimmable-type-map labels Apr 29, 2026
simonrozsival and others added 2 commits April 29, 2026 10:54
For every non-aliased, non-generic peer (excluding JNI primitive-keyword
keys), the generator can now emit three speculative `TypeMap<T>` entries
keyed by the **element JNI name** and anchored to per-typemap-assembly
`__ArrayMapRank{1,2,3}` sentinel TypeDefs:

  [assembly: TypeMap<__ArrayMapRank1>("java/lang/String", typeof(string[]),     typeof(string[]))]
  [assembly: TypeMap<__ArrayMapRank2>("java/lang/String", typeof(string[][]),   typeof(string[][]))]
  [assembly: TypeMap<__ArrayMapRank3>("java/lang/String", typeof(string[][][]), typeof(string[][][]))]

The trim target is the closed array type itself, so ILC's per-shape
conditional drops entries when the array shape is never constructed —
validated on disjoint-set NativeAOT tests for both reference and value
type element peers.

* `Generator/Model/TypeMapAssemblyData.cs`:
  * `TypeMapAssemblyData.RankSentinels` (nullable `RankSentinelNames`)
    — when set, the emitter generates the three sentinel TypeDefs.
  * `TypeMapAttributeData.AnchorRank` (nullable int) — when set,
    overrides the model-level default anchor with the rank-{value}
    sentinel from the same assembly.

* `Generator/TypeMapAssemblyEmitter.cs`:
  * `EmitRankSentinels` mirrors the existing `__TypeMapAnchor` pattern.
  * Per-anchor `TypeMap<TGroup>` 3-arg ctor refs are now built and
    cached lazily by anchor handle (`GetOrAddTypeMapAttr3ArgCtorRef`).
  * `EmitTypeMapAttribute` resolves rank-anchored entries to the local
    sentinel handle.

* `Generator/ModelBuilder.cs`:
  * `Build` takes a new `bool emitArrayEntries` (default false).
    When true, sets `RankSentinels` and routes each peer through
    `EmitArrayEntries`, which produces the per-rank trio.
  * Skip rules: open-generic peers, JNI primitive-keyword keys, alias
    groups (would produce duplicate keys; deferred).

* `tests/.../TypeMapModelBuilderTests.cs`: 14 new tests covering the
  default-off behavior, default sentinel names, per-rank-trio emission,
  element-only key, closed-array trim target, conditional-only entries,
  open-generic / alias / primitive-keyword skip rules, multiple-peer
  isolation, and PE blob round-trip (sentinels emitted, attribute
  blobs survive).

Tracking: #11234 Phase 2 (arrays-only, container types stay untouched).
Runtime wiring + MSBuild flag follow in subsequent commits.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`JNIEnv.ArrayCreateInstance` now branches on
`RuntimeFeature.IsDynamicCodeSupported`:

* CoreCLR / Mono (true) — `Array.CreateInstance(elementType, length)`.
  No typemap roundtrip; supports unlimited array rank.
* NativeAOT (false) — typemap lookup &#8594; AOT-safe
  `Array.CreateInstanceFromArrayType`. Capped at the emitted ranks
  (1–3); miss throws `NotSupportedException` with diagnostic.

The runtime fork lets us avoid emitting (and paying for) speculative
array TypeMap entries on CoreCLR-only builds, where the runtime type
loader can construct any `T[]` dynamically anyway.

* `ITypeMapWithAliasing.TryGetArrayType(string jniElementTypeName,
  int rank, out Type? arrayType)` — new abstraction for the per-rank
  array dictionary lookup. `SingleUniverseTypeMap` carries three
  nullable `IReadOnlyDictionary<string, Type>?` fields (rank 1, 2, 3)
  populated at `TrimmableTypeMap.Initialize` time;
  `AggregateTypeMap` does first-wins iteration.

* `TrimmableTypeMap.TryGetArrayType(Type elementType, out Type?)` —
  walks down `elementType.IsArray` / `GetElementType()` to find the
  leaf type and array depth, resolves the leaf JNI element name
  (primitive static dict OR `TryGetJniNameForManagedType` wrapped),
  and delegates the (jni, rank+1) lookup to the interface.

* `TrimmableTypeMap.Initialize` gains 5-arg overloads (single +
  aggregate) accepting the per-rank dicts. Existing 2-arg overloads
  stay as wrappers passing null per-rank dicts so older generated
  assemblies keep working.

* `RootTypeMapAssemblyGenerator`: the generated `TypeMapLoader.Initialize`
  IL now branches on the new `emitArrayEntries` flag. When true, it
  collects per-assembly `__ArrayMapRank{1,2,3}` sentinels via
  `TypeMapping.GetOrCreateExternalTypeMapping<__ArrayMapRank{N}>()` and
  passes the resulting dicts to the 5-arg `TrimmableTypeMap.Initialize`.
  Aggregate (Debug) path is fully implemented; merged-universe (Release)
  path throws at generation time with a clear message — wiring the
  shared-universe array sentinels is a small follow-up.

* `GenerateTrimmableTypeMap` MSBuild task: new `EmitArrayEntries`
  property forwarded through `TrimmableTypeMapGenerator.Execute` and
  `RootTypeMapAssemblyGenerator.Generate`. SDK target sets it to
  `$(PublishAot)`.

* `JavaPeerContainerFactory<T>.CreateArray` and
  `CreateHigherRankArray` deleted. Container methods (`CreateList`,
  `CreateCollection`, `CreateDictionary*`) stay untouched — those
  are tracked separately in #11234.

Validation:

* 445 / 445 generator unit tests pass.
* Trimmable + CoreCLR `RunTestApp` lane on emulator:
  **917 total / 0 errors / 3 failures** (pre-existing
  `TryGetJniNameForManagedType_*`, called out as out-of-scope in
  #11225). No regression.
* The NativeAOT branch path is gated on dotnet/runtime#126380 (ships
  in .NET 11 nightly preview.5+); validated with the playground repro
  separately.

Tracking: #11234 Phase 2 (arrays only).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@simonrozsival simonrozsival force-pushed the dev/simonrozsival/trimmable-array-typemap branch from 5b32352 to 956a999 Compare April 29, 2026 09:28
@simonrozsival simonrozsival changed the title [TrimmableTypeMap] Add array-typemap scaffolding (blocked on ILLink fix) [TrimmableTypeMap] Replace per-T array factory with runtime fork + per-rank typemap Apr 29, 2026
@simonrozsival simonrozsival changed the title [TrimmableTypeMap] Replace per-T array factory with runtime fork + per-rank typemap [TrimmableTypeMap] Improve array handling Apr 29, 2026
simonrozsival and others added 13 commits April 29, 2026 12:10
* JNIEnv.ArrayCreateInstance:
  * Drop the IL3050 #pragma — IsDynamicCodeSupported acts as a
    [FeatureGuard] so the trimmer dead-codes the dynamic branch under
    PublishAot without needing a manual suppression.
  * Drop the 'Mono' mention from the comment — the trimmable typemap
    is a CoreCLR-only feature.

* SingleUniverseTypeMap._arrayMapsByRank:
  * Switched from a 1-indexed array of fixed length 4 (with [0] unused)
    to a 0-indexed variable-length array (length determined by what the
    generator emitted). TryGetArrayType uses (rank - 1) for indexing
    and bounds-checks to the actual length.

* Variable rank count throughout:
  * RankSentinelNames: replaced the fixed (Rank1, Rank2, Rank3) record
    with a name list + Count, generated via CreateDefault(maxRank).
  * TypeMapAssemblyEmitter: _rankAnchorHandles becomes a 0-indexed
    variable-length array sized to model.RankSentinels.Count.
  * ModelBuilder.Build: replaced 'bool emitArrayEntries' with
    'int maxArrayRank' (0 = disabled). EmitArrayEntries takes the max
    and loops 1..maxArrayRank instead of using a const.
  * RootTypeMapAssemblyGenerator: 'int maxArrayRank' parameter; the
    generated TypeMapLoader.Initialize now builds a single jagged
    'IReadOnlyDictionary<string, Type>?[][]' (per-universe per-rank)
    instead of fixed-rank-count locals. Rank loop is unrolled.
  * GenerateTrimmableTypeMap MSBuild task: 'int MaxArrayRank' property.

* New $(_AndroidTrimmableTypeMapMaxArrayRank) MSBuild property —
  defaults to 3 under PublishAot=true, 0 otherwise. Users can override
  to support unlimited rank under NativeAOT (limited to what they're
  willing to pay in attribute metadata).

* TrimmableTypeMap.Initialize:
  * Single overload: '(typeMap, proxyMap, IReadOnlyDictionary<string,
    Type>?[]? arrayMapsByRank)' — 0-indexed by (rank - 1).
  * Aggregate overload: '(typeMaps[], proxyMaps[],
    IReadOnlyDictionary<string, Type>?[]?[]? perUniverseArrayMaps)' —
    jagged, indexed first by universe, then by (rank - 1).
  * Existing 2-arg overloads retained as wrappers passing null.

* Tests: updated BuildModelWithArrays to take maxArrayRank (default 3).
  Replaced 'RankSentinels.Rank{1,2,3}' assertions with Names[i] indexing.
  Added Build_EmitArrayEntries_HonoursMaxArrayRank test that verifies
  emission with maxArrayRank=1 and =5.

Validation:
* 446 / 446 generator unit tests pass (445 baseline + 1 new for the
  MaxArrayRank parametrization).
* Trimmable + CoreCLR RunTestApp lane on emulator: 917 / 0 errors / 3
  pre-existing failures. No regression.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tightens XML doc comments and inline notes added in this PR — keeps the
intent but drops walls of explanation that were repeating what the code
already says.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replaces the NotSupportedException stub in RootTypeMapAssemblyGenerator
with a fully functional shared-universe + array entries IL emit path,
covering the Release build configuration when array typemap entries
are enabled (`_AndroidTrimmableTypeMapMaxArrayRank > 0`).

Each per-assembly typemap DLL still owns its own `__ArrayMapRank{N}`
sentinel TypeDefs, so each rank's entries are split across N per-asm
dicts. The new `CompositeStringTypeReadOnlyDictionary` wraps an array
of source dicts and routes `TryGetValue` first-hit, letting the existing
single-universe-with-arrays `TrimmableTypeMap.Initialize` overload
consume them as a single per-rank dict each.

Generated IL (shared + arrays):

    var arrayMapsByRank = new IReadOnlyDictionary<string, Type>?[maxRank];
    for each rank r:
        var sources = new IReadOnlyDictionary<string, Type>?[asmCount];
        sources[i] = TypeMapping.GetOrCreateExternalTypeMapping<PerAsm[i].__ArrayMapRank_r>();
        arrayMapsByRank[r-1] = new CompositeStringTypeReadOnlyDictionary(sources);
    TrimmableTypeMap.Initialize(
        TypeMapping.GetOrCreateExternalTypeMapping<Java.Lang.Object>(),
        TypeMapping.GetOrCreateProxyTypeMapping<Java.Lang.Object>(),
        arrayMapsByRank);

Also extends `IgnoresAccessChecksTo` to cover per-asm DLLs in shared
mode when arrays are enabled (root needs to reach their internal sealed
`__ArrayMapRank{N}` types).

Adds 4 generator unit tests; all 450 tests pass. Validated default
Release CoreCLR trimmable lane: 917/0/3 (matches baseline).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Removes obvious redundant inline comments and trims a couple of doc
summaries; behavior unchanged. 450/450 unit tests still pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds an IsSZArray guard inside the leaf walk so multi-dimensional
arrays (e.g. byte[,], byte[,][]) bail out cleanly rather than walking
through GetElementType() as if they were jagged szarray chains. JNI
only supports single-dim zero-based arrays so any multi-dim element
type reaching this method indicates a caller bug — return false rather
than silently producing a misleading rank-N lookup.

In practice this is unreachable from MCW-generated marshaling (JNI
arrays are always szarrays) so behavior is unchanged for real callers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replaces TypeMapAssemblyData.RankSentinels (a RankSentinelNames wrapper
class storing a list of derived-from-rank strings) with a plain int
MaxArrayRank field. The sentinel names are always `__ArrayMapRank{N}`
by convention — both the per-asm emitter and the runtime/root loader
hard-code that pattern — so the wrapper added no value beyond rank
storage.

Removes ~35 LOC and a public-ish helper class. 450/450 unit tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Folds the multi-source first-hit logic into SingleUniverseTypeMap by
making its rank dict storage jagged (`[rank-1][source]`). The composite
class was just a thin wrapper around a list of dicts that did first-hit
TryGetValue — exactly what the type map's own array lookup needs to do.
Native jagged storage avoids the wrapper allocation, removes one type,
and eliminates a member-ref + newobj from the generated IL per rank.

Initialize signature changes: the single-universe-with-arrays overload
now takes `IReadOnlyDictionary<string,Type>?[]?[]?` (jagged) instead of
`IReadOnlyDictionary<string,Type>?[]?`. The aggregate path wraps each
universe's 1-source rank dicts into 1-element-inner-jagged at construction
time so SingleUniverseTypeMap's storage shape is uniform.

Mono.Android Release builds clean. 449/449 unit tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Defines `Microsoft.Android.Runtime.__ArrayMapRank1` ... `__ArrayMapRank8`
as internal sealed types in Mono.Android. Per-asm typemap DLLs no longer
emit their own rank sentinel TypeDefs — they reference the shared anchors
instead. Per rank, every per-asm DLL contributes to the SAME TypeMap
group, so `TypeMapping.GetOrCreateExternalTypeMapping<__ArrayMapRankN>()`
returns ONE merged dict spanning all per-asm DLLs.

Knock-on simplifications:
- `SingleUniverseTypeMap` and `AggregateTypeMap` no longer hold rank dicts;
  the rank lookup table lives directly on `TrimmableTypeMap` (singleton)
  since it's logically global, not per-universe.
- `ITypeMapWithAliasing.TryGetArrayType` removed — no longer needed.
- `Initialize(typeMaps[], proxyMaps[], perUniverseArrayMaps[][])` collapses
  to `Initialize(typeMaps[], proxyMaps[], arrayMapsByRank[])` (1D, same
  shape as the single-universe overload). Both modes pass the SAME
  array-shape — the only thing that varies is whether the main typemap
  is single (shared) or N-element (aggregate).
- Root assembly IL: shared and aggregate paths share an
  `EmitBuildArrayMapsByRank` helper that emits `maxArrayRank` calls to
  `GetOrCreateExternalTypeMapping<Mono.Android.__ArrayMapRankN>()` directly.
  Shared mode no longer needs IgnoresAccessChecksTo on per-asm DLLs.
- The supported maximum is `MaxSupportedArrayRank = 8`. The generator
  throws `ArgumentOutOfRangeException` if `_AndroidTrimmableTypeMapMaxArrayRank`
  exceeds it (with a message pointing at the Mono.Android types to add for
  more).

Net -82 LOC. Mono.Android Release builds clean. 449/449 unit tests pass.
Device test (Release CoreCLR trimmable): 917 / 0 errors / 3 pre-existing
failures — matches baseline.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Drops the unreachable elementType null check (param is non-nullable
under #nullable enable), collapses the redundant rank/index arithmetic
(rankIndex = elementDepth = rank - 1), and folds the JNI name
resolution into a single ternary. Behavior unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Collapses 4 IL emit paths × 4 MemberRef helpers in the root assembly
generator down to 2 of each. Both paths always emit the 3-arg
`Initialize` (with arrayMapsByRank); when maxArrayRank=0 the new
`EmitArrayMapsByRankOrNull` helper just emits `ldnull`.

Also drops the matching no-array `Initialize` C# overloads from
`TrimmableTypeMap` since nothing calls them — the generated root
assembly always picks the 3-arg shape now. The internal callers can
pass `arrayMapsByRank: null`.

Net -157 LOC. 449/449 unit tests pass; Mono.Android Release builds clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1. EmitTypeMapLoader: only build the Initialize MemberRef for the path
   actually taken (was building both shared and aggregate variants
   unconditionally — added a stray MemberRef row to every assembly).

2. ModelBuilder.Brackets: replace LINQ Enumerable.Repeat fallback for
   rank>=4 with a straightforward StringBuilder loop.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Drops a redundant `if (maxArrayRank > 0)` guard (the default is 0
already and the parameter is validated non-negative above) and inlines
`moduleName`. Behavior unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The == default check on _rankAnchorHandles[i] was redundant — since
EmitRankSentinels populates all slots up to MaxArrayRank, the
length check is sufficient. The error message also referenced 'TypeDef'
when we now emit TypeRefs (shared anchors live in Mono.Android).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@simonrozsival
Copy link
Copy Markdown
Member Author

/review

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 29, 2026

Android PR Reviewer completed successfully!

@simonrozsival simonrozsival marked this pull request as ready for review April 29, 2026 19:56
Copilot AI review requested due to automatic review settings April 29, 2026 19:56
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

✅ LGTM — Clean design, minor doc/style nits

Well-architected PR. The runtime fork (CoreCLR → Array.CreateInstance directly, NativeAOT → per-rank typemap + Array.CreateInstanceFromArrayType) is sound and avoids unnecessary typemap lookups on CoreCLR while remaining AOT-safe on NativeAOT.

What looks good:

  • IL emission for both aggregate (Debug) and single-universe (Release) paths is correct — stack discipline, local variable encoding, and member reference blobs all check out.
  • Thorough edge-case handling in EmitArrayEntries: primitives skipped (dedicated JNI handlers exist), open generics rejected (typeof(T<>[]) invalid), alias groups deferred.
  • TryGetArrayType's unwrap loop correctly handles nested szarrays and rejects multi-dim arrays.
  • 14+ new generator unit tests cover the key scenarios including PE round-trip verification.
  • Sentinel types in ArrayMapAnchors.cs mirror the established __TypeMapAnchor pattern nicely.
  • MaxSupportedArrayRank = 8 matches the 8 anchor types, with the MSBuild default of 3 for NativeAOT being practical for typical apps.

Minor nits (3 inline 💡):

Sev Category File
💡 Formatting Array.Empty<T>()[] (2 instances)
💡 Documentation EmitArrayEntries doc comment mentions [L<jni>;-shaped keys but keys are bare element names
💡 Documentation Stale 2-arg Initialize references in comments

No correctness, safety, or performance issues found.

Generated by Android PR Reviewer for issue #11238 · ● 9.8M


// Per-rank array sentinel TypeDefs, 0-indexed by (rank - 1). Empty when array entries
// aren't emitted.
EntityHandle [] _rankAnchorHandles = Array.Empty<EntityHandle> ();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 💡 Formatting — Repo convention prefers [] over Array.Empty<T>().

Suggested change
EntityHandle [] _rankAnchorHandles = Array.Empty<EntityHandle> ();
EntityHandle [] _rankAnchorHandles = [];

Same applies to TrimmableTypeMap.cs line 41:

_arrayMapsByRank = arrayMapsByRank ?? [];

Rule: Use [] not Array.Empty<T>()

Comment on lines +75 to +78
/// <param name="maxArrayRank">
/// Maximum array rank for which per-assembly typemaps emitted <c>__ArrayMapRank{N}</c>
/// sentinels. Must match the value passed to the per-assembly generators. 0 means
/// no array sentinels were emitted; the loader uses the 2-arg <c>Initialize</c>.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 💡 Documentation — The xml-doc says "the loader uses the 2-arg Initialize", but the old 2-arg overloads were replaced by the new 3-arg overloads that always take arrayMapsByRank (passing null when 0). Consider updating:

	/// no array sentinels were emitted; the loader passes <c>null</c> for <c>arrayMapsByRank</c>.

Also, the class-level pseudo-C# example at lines 36–49 still shows the old 2-arg TrimmableTypeMap.Initialize(typeMaps, proxyMaps) call — updating that would keep it consistent with the new 3-arg signature.

Rule: Stale comments (Postmortem #59)

Comment on lines +408 to +410
/// Emits per-rank <c>[L&lt;jni&gt;;</c>-shaped TypeMap entries for one peer, anchored to
/// the per-assembly <c>__ArrayMapRank{N}</c> sentinels. Skips open generics, primitive
/// JNI keyword keys (handled by the legacy primitive-array path), and alias groups.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 💡 Documentation — The doc comment says "[L<jni>;-shaped TypeMap entries", but the design choice here is specifically to use the bare element JNI name as the key (no [L...; wrapping). The test Build_EmitArrayEntries_KeyIsElementJniName documents this explicitly. Consider rewording to avoid confusion:

Suggested change
/// Emits per-rank <c>[L&lt;jni&gt;;</c>-shaped TypeMap entries for one peer, anchored to
/// the per-assembly <c>__ArrayMapRank{N}</c> sentinels. Skips open generics, primitive
/// JNI keyword keys (handled by the legacy primitive-array path), and alias groups.
/// Emits per-rank array TypeMap entries for one peer, anchored to
/// the per-assembly <c>__ArrayMapRank{N}</c> sentinels. Keys are bare element JNI names
/// (rank is encoded by the sentinel anchor, not by JNI array prefixes). Skips open generics,
/// primitive JNI keyword keys (handled by the legacy primitive-array path), and alias groups.

Rule: Comments explain "why", not "what" — and shouldn't contradict the implementation

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Refactors trimmable typemap array creation to avoid per-T factory IL bloat by routing array construction through JNIEnv.ArrayCreateInstance, using Array.CreateInstance when dynamic code is supported and a per-rank array typemap under NativeAOT.

Changes:

  • Add generator support for per-rank array TypeMap entries (with rank anchors) and plumb MaxArrayRank through the MSBuild task + targets.
  • Update the runtime typemap initialization and lookup to support array-type resolution under NativeAOT, and update JNIEnv.ArrayCreateInstance to use the new runtime fork.
  • Remove JavaPeerContainerFactory<T> array creation APIs and update tests to cover the new generator/root-assembly behavior.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs Adds unit tests validating array-entry model emission (ranks, keys, skip rules, blob round-trip).
tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/RootTypeMapAssemblyGeneratorTests.cs Adds tests for merged-universe root assembly generation when arrays are enabled.
src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs Adds MaxArrayRank task input and forwards it into typemap generation.
src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets Introduces $(_AndroidTrimmableTypeMapMaxArrayRank) defaults and passes it to the task.
src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs Extends initialization to accept per-rank array dictionaries and adds TryGetArrayType lookup used by NativeAOT.
src/Mono.Android/Microsoft.Android.Runtime/ArrayMapAnchors.cs Adds shared __ArrayMapRank{N} anchor types in Mono.Android for rank grouping.
src/Mono.Android/Java.Interop/JavaPeerContainerFactory.cs Removes array creation APIs from the container factory surface (arrays move to JNIEnv).
src/Mono.Android/Android.Runtime/JNIEnv.cs Implements the runtime fork for array creation (dynamic-code vs NativeAOT typemap).
src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs Adds MaxSupportedArrayRank validation and threads maxArrayRank through generation.
src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs Passes maxArrayRank into ModelBuilder.Build.
src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs Emits rank anchor refs and supports rank-anchored 3-arg TypeMap<TGroup> constructor emission.
src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/RootTypeMapAssemblyGenerator.cs Emits root-loader initialization that passes array rank dictionaries (or null) into TrimmableTypeMap.Initialize.
src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs Emits per-rank array entries into the typemap model when enabled.
src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/Model/TypeMapAssemblyData.cs Adds MaxArrayRank and per-entry AnchorRank metadata for array mapping.

Comment on lines +49 to 57
public static void Initialize (
IReadOnlyDictionary<string, Type> typeMap,
IReadOnlyDictionary<Type, Type> proxyMap,
IReadOnlyDictionary<string, Type>?[]? arrayMapsByRank)
{
ArgumentNullException.ThrowIfNull (typeMap);
ArgumentNullException.ThrowIfNull (proxyMap);
InitializeCore (new SingleUniverseTypeMap (typeMap, proxyMap));
InitializeCore (new SingleUniverseTypeMap (typeMap, proxyMap), arrayMapsByRank);
}
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

TrimmableTypeMap.Initialize previously had 2-arg overloads; this change replaces them with 3-arg overloads only. That’s a breaking API change and can also break older generated _Microsoft.Android.TypeMaps assemblies at runtime (MissingMethodException) if they still call the 2-arg overloads. Consider re-introducing the 2-arg overloads as wrappers that forward null for arrayMapsByRank to preserve backward compatibility.

Copilot uses AI. Check for mistakes.
Comment on lines +358 to +366
if ((uint)rankIndex >= (uint)_arrayMapsByRank.Length || _arrayMapsByRank [rankIndex] is not { } dict) {
return false;
}

string? leafJniName = leaf.IsPrimitive
? TryGetPrimitiveJniName (leaf, out var p) ? p : null
: TryGetJniNameForManagedType (leaf, out var jni) ? jni : null;

return leafJniName is not null && dict.TryGetValue (leafJniName, out arrayType);
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

TryGetArrayType currently relies on _arrayMapsByRank lookups using primitive JNI keywords (Z/B/C/...). However the generator intentionally skips emitting array entries for primitive JNI keyword keys, so NativeAOT + trimmable typemap will fail to create jagged primitive arrays (e.g. byte[][] where elementType is typeof(byte[])), leading to JNIEnv.ArrayCreateInstance throwing NotSupportedException. Please add an AOT-safe fallback for primitive-leaf arrays (e.g. a precomputed (primitive, rank)->Type table) or ensure primitive-key array entries are present in the rank maps.

Suggested change
if ((uint)rankIndex >= (uint)_arrayMapsByRank.Length || _arrayMapsByRank [rankIndex] is not { } dict) {
return false;
}
string? leafJniName = leaf.IsPrimitive
? TryGetPrimitiveJniName (leaf, out var p) ? p : null
: TryGetJniNameForManagedType (leaf, out var jni) ? jni : null;
return leafJniName is not null && dict.TryGetValue (leafJniName, out arrayType);
bool isPrimitiveLeaf = leaf.IsPrimitive;
if ((uint)rankIndex < (uint)_arrayMapsByRank.Length && _arrayMapsByRank [rankIndex] is { } dict) {
string? leafJniName = isPrimitiveLeaf
? TryGetPrimitiveJniName (leaf, out var p) ? p : null
: TryGetJniNameForManagedType (leaf, out var jni) ? jni : null;
if (leafJniName is not null && dict.TryGetValue (leafJniName, out arrayType)) {
return true;
}
}
// Primitive leaf arrays intentionally do not have primitive-keyed rank-map entries,
// so construct the next szarray type directly as an AOT-safe fallback.
if (isPrimitiveLeaf) {
arrayType = elementType.MakeArrayType ();
return true;
}
return false;

Copilot uses AI. Check for mistakes.
// dictionary at runtime via TypeMapping.GetOrCreateExternalTypeMapping<__ArrayMapRankN>().
//
// To support a higher MaxArrayRank, add additional types here and bump
// TrimmableTypeMap.MaxSupportedArrayRank.
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

The comment references TrimmableTypeMap.MaxSupportedArrayRank, but the max-rank constant introduced in this PR lives in TrimmableTypeMapGenerator.MaxSupportedArrayRank. Please update the comment to point at the correct symbol/location so future rank bumps don’t miss updating the generator limit.

Suggested change
// TrimmableTypeMap.MaxSupportedArrayRank.
// TrimmableTypeMapGenerator.MaxSupportedArrayRank.

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +24
<!-- Max array rank for __ArrayMapRank{N} sentinel emission. Defaults to 3 under AOT (CoreCLR
reaches Array.CreateInstance directly and skips the typemap roundtrip). -->
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

The comment says "Defaults to 3 under AOT (CoreCLR reaches Array.CreateInstance directly and skips the typemap roundtrip)", but $(PublishAot)==true is exactly the scenario where RuntimeFeature.IsDynamicCodeSupported is false and JNIEnv.ArrayCreateInstance must use the typemap/Array.CreateInstanceFromArrayType path. Please reword this comment to reflect the actual behavior (e.g., default 3 for NativeAOT; 0 for non-AOT where dynamic code can use Array.CreateInstance).

Suggested change
<!-- Max array rank for __ArrayMapRank{N} sentinel emission. Defaults to 3 under AOT (CoreCLR
reaches Array.CreateInstance directly and skips the typemap roundtrip). -->
<!-- Max array rank for __ArrayMapRank{N} sentinel emission. Defaults to 3 for NativeAOT
(PublishAot=true, dynamic code is unavailable, so array creation uses the typemap path);
defaults to 0 otherwise, where dynamic code can use Array.CreateInstance directly. -->

Copilot uses AI. Check for mistakes.
/// <param name="maxArrayRank">
/// Maximum array rank for which per-assembly typemaps emitted <c>__ArrayMapRank{N}</c>
/// sentinels. Must match the value passed to the per-assembly generators. 0 means
/// no array sentinels were emitted; the loader uses the 2-arg <c>Initialize</c>.
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

The maxArrayRank doc says the loader uses the 2-arg Initialize overload when maxArrayRank is 0, but the generator now emits calls to the 3-arg Initialize(..., arrayMapsByRank) and passes null when maxArrayRank == 0. Please update this doc comment to match the emitted IL.

Suggested change
/// no array sentinels were emitted; the loader uses the 2-arg <c>Initialize</c>.
/// no array sentinels were emitted; the loader still calls the 3-arg <c>Initialize</c>
/// overload and passes <see langword="null"/> for the array-maps argument.

Copilot uses AI. Check for mistakes.
Comment on lines +243 to +250
[Fact]
public void Generate_MergedMode_WithArrays_ProducesValidPEAssembly ()
{
using var stream = GenerateRootAssembly (["_App.TypeMap", "_Mono.Android.TypeMap"],
useSharedTypemapUniverse: true, maxArrayRank: 3);
using var pe = new PEReader (stream);
Assert.True (pe.HasMetadata);
}
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

PR description states that merged-universe (Release) array emission currently throws at generation time and needs a follow-up, but these new merged-mode array tests indicate the merged-universe path is implemented. Please update the PR description (or the code/tests) so they agree about whether merged-mode array emission is supported in this PR.

Copilot uses AI. Check for mistakes.

throw new NotSupportedException (
$"No TrimmableTypeMap array entry for element type '{elementType}'. " +
$"Add an [assembly: TypeMap] entry for the closed array type or report an issue.");
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

The NotSupportedException guidance is misleading: TryGetArrayType looks up entries in the per-rank __ArrayMapRank{N} TypeMap groups keyed by the element JNI name, so adding a generic [assembly: TypeMap] entry for the closed array type may not fix the issue unless it’s anchored to the correct rank group. Consider updating the message to mention the required __ArrayMapRank{rank} group / MaxArrayRank setting (or point users at the MSBuild property that controls emission) so the suggested remediation is accurate.

Suggested change
$"Add an [assembly: TypeMap] entry for the closed array type or report an issue.");
$"Array lookups use the element type within the per-rank '__ArrayMapRank1' typemap group; ensure the mapping is emitted for rank 1 (for example by increasing MaxArrayRank) or report an issue.");

Copilot uses AI. Check for mistakes.
Base automatically changed from dev/simonrozsival/trimmable-typemap-javacast to main April 30, 2026 19:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

copilot `copilot-cli` or other AIs were used to author this trimmable-type-map

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants