Done, 2026-08-28. vector<x> in td_api.tl is generated as an immutable Vector<T>, with
MutableVector<T> for the callers that build one. The app runs. This note is the record of why,
because the reasons it was originally argued for all turned out to be wrong and the reason it paid
off was not predicted by anyone.
- It was argued for analyzer visibility. That died twice over - see the two sections below.
- It was worth doing for the allocation. The parser materialised 25,361 empty
List<T>a minute - 792 KB, 71.6% of all vectors - and a sharedVector<T>.Emptycosts nothing. That win needs no custom type;Array.Empty<T>()in anIList<T>slot would do. What needs the type is the audit: the empty may only be shared if nothing writes to it, and this app is far too large to establish that by grep (two methods overlapped on 25 of 51 sites) or by exercising it (dozens of screens, unknowable coverage). Only the compiler can enumerate them. - The payoff nobody predicted: what crosses the WinRT ABI became knowable again. Not through
TG1001, but because the candidate set collapsed from unbounded (
List<T>,T[], anything implementingIList<T>) to exactly two named types, one of which -MutableVector<T>, 171 mentions across 45 files - only exists where someone typed it. That is greppable.
It would have got the allocation win identically: Array.Empty<T>() satisfies it, mutation is a
compile error, .Count works through IReadOnlyCollection<T>, and the compiler-driven audit is the
same. No new type, no System.Numerics clash, no new instantiations. Two things stop it:
- The concrete type goes unbounded again, which is the payoff above, lost.
- Collection expressions become a WinRT hazard. Measured:
[]targetingIReadOnlyList<T>compiles toString[],[x]to<>z__ReadOnlySingleElementList<T>,[x, y]to<>z__ReadOnlyArray<T>- synthesised types that can never have a CCW, and the type changes with the element count. That is exactly what TG1003 exists to catch.
A binding assigns through the declared type. With IList<T> the concrete type exists only at
runtime, so TG1001 could never see it, CsWinRT.Vectors.cs had to blanket-register 188
instantiations, and every vector a new schema added looked like an open-ended blind spot -
SettingsStoragePage threw E_INVALIDARG with nothing to warn on.
That premise does not survive, for two independent reasons recorded below. It is left here because it is what the work started from, and because the reasoning was sound while the conclusion was not - which is the kind of mistake worth being able to retrace.
System.Numerics.Vector<T> exists and 216 files import System.Numerics. Verified by compiling:
a generic using alias is not legal C# at all (global using Vector<T> = ... is a syntax error),
and global using Telegram.Td; still yields CS0104. Declaring it in the root Telegram
namespace resolves it - an enclosing namespace is searched before any using directive, and every
file in the app is under Telegram.*. Common/WatchDog.cs and Strings/en/Resources.cs already
sit in subfolders under that namespace.
Build of Telegram.Modern.csproj with the generator flipped to emit Vector<T> for objects and
functions:
- 591 errors, of which 351 are in the generated
TdDotNetApi.g.csand are all one cause - the parsers still returnList<T>. One fix, not 351. - 236 app sites across 70 files: 159 argument conversions (95 from
List<T>, 64 fromIList<T>), 27 assignments, 32 mutations, 18 assorted. - 57 of the 236 construct a function, so keeping functions on
IList<T>would save about a quarter. Not taken: the split makes "functions are never bound" an invariant nobody can check.
Second build with IList<T> and the non-generic IList removed while IReadOnlyList<T> was kept,
so that every escape into a slot that could mutate fails to compile while foreach and LINQ stay
quiet:
- 448 app sites across 110 files - 212 more.
- 192 of the new errors escape into
IList<…>; zero intoIEnumerable<T>,IReadOnlyList<T>,IReadOnlyCollection<T>orICollection<T>, which is the check that the experiment isolated what it meant to. - Resolved: 11 escape into a helper that mutates its
IList<T>parameter, 8 are stored into a field mutated elsewhere, 140 into read-only sinks, 27 unresolved.
So roughly 236 direct edits plus 212 declaration tightenings. The tightenings are individually trivial and are what buys TG1001 the visibility this is all for, but each needs a glance to confirm the callee does not mutate.
70 places write into a list that came from TDLib - see [tdlib-vector-mutations.md]. Two views of the direct ones overlap on only 25 of 51: 7 compiler-only, 19 grep-only, so neither method alone finds the list.
Two shapes are mixed together and want opposite treatment. ClientService mutating a cached object
under lock (value) to apply an update is deliberate and correct today; immutability turns each
into a rebuild, costing an allocation on the update path in exchange for readers getting a
consistent snapshot rather than a torn one. Everything in Controls\ and most of ViewModels\ is
the other shape - app code editing a list it was merely handed, on an object shared with the cache.
9 Ptr readers, 9 Reader-family readers, 8 WriteArray overloads.
- Reads. JSON arrays carry no length, so accumulate and hand over an exact array: grow a plain
T[]by doubling andArray.Resizeto exact at the end.ArrayPool<T>.Sharedwas considered and rejected - it is a static per closed type, so 189 element types means 189 pool object graphs, each with per-core locked stacks and a thread-static bucket array. stackallocdoes not apply. It requires an unmanaged type (CS0208) and 184 of the 189 element types are reference types.[InlineArray]would work - verified, references in an inline array survive a GC - but it is a runtime layout feature ILC does not implement, so it is out on .NET Native. Onlyvector<int32>andvector<int53>/int64could usestackalloc, and only with a cap and a heap fallback, since the parser recurses and a stack buffer per frame multiplies with nesting depth.- The empty case is the prize. Today every optional vector allocates a
List<T>to say "nothing here".Vector<T>.Emptymakes that zero, on every object of every update, and dropsList<T>'s retained up-to-2x capacity slack besides. - Writes get simpler.
WriteArray'sis List<T>/is T[]/foreachtriple collapses to one loop overAsSpan(): no type test, no interface dispatch, bounds checks eliminable.
Checked 2026-08-27 against 3.0.0-preview.260319.2 (published 2026-03-25) and its
spec.
GeneratedWinRTExposedExternalType, WinRTExposedType and RegisterTypeComInterfaceEntriesLookup
are gone from both the runtime and the generator. The registration table is replaced by a
post-build IL analysis tool producing a WinRT.Interop.dll sidecar, which per the spec generates
marshalling code "with a global program view" covering "all marshalling code (including CCW support)
for generic type instantiations", "all WinRT-exposed types (user-defined and not)", and explicitly
"internal/synthesized types too (just like .NET Native could)". And: "the proxy type map in
particular means no user action is needed to enable marshalling".
Being a post-build IL tool rather than a source generator, it does not have the generator-cannot-see-generator constraint at all - by then the SchemaGenerator's output is compiled and resolved. So the problem this whole investigation circled is fixed upstream, at the root.
What that means here:
CsWinRT.Vectors.csand most ofCsWinRT.csbecome unnecessary.- TG1001 and TDAPI003 both lose their purpose.
- The 1967-comparison string lookup goes: "0 overhead, 0 allocation for all vtables in the entire
application domain", pre-initialised by ILC into
.rdata. - The 12 inert entries stop mattering, since nothing is inferred from syntax any more.
And it settles the type question rather than reopening it. The fix removes the registration rather
than making inference smarter, so a concrete declared type buys nothing: IList<T> marshals fine
because the sidecar reads the real instantiations out of IL, not out of a declaration.
Not to be chased now. It is a first preview, an explicitly breaking release, and adoption needs a new
TFM (net10.0-windows10.0.26100.0 -> ...26100.1). 2.x is still maintained - tag 2.3.1.260716.1
postdates the 3.0 preview. Note also that 3.0 projects WinRT T[] parameters as
ReadOnlySpan<T>/Span<T>, source compatible via C# 14 first-class spans but touching the
Telegram.Native interop surface.
Source generators all run against the same input compilation, so CsWinRT's optimizer cannot
resolve anything the SchemaGenerator produced. When it infers a registration from a call site
touching a TDLib type it has only the syntax to work from, so it writes the bare name into
WinRTGlobalVtableLookup.g.cs - and Type.ToString() always produces the namespace-qualified form,
so the entry can never match. Measured on a Release build: of the 41 distinct unqualified element
types in that lookup, 40 are types the SchemaGenerator emits; the one exception is TResult, an
open generic parameter. Every one of those entries is dead on arrival. The 466 qualified
Telegram.Td.Api.* entries are the ones a human spelled out in CsWinRT.Vectors.cs.
The declared type changes none of this. IList<T>, List<T> and Vector<T> are equally invisible
to CsWinRT for the same reason, so no type buys an inferred registration.
TG1001 is an analyzer rather than a generator, so it does see generated code and would still report
the reachable set. But acting on that means replacing a blanket list that is complete by
construction with a hand-maintained subset derived from an analyzer already known to miss things -
the 12 inert entries, the 83 app collections. That trades binary size for a class of runtime
E_INVALIDARG the blanket file structurally cannot have.
So CsWinRT.Vectors.cs is not a workaround waiting to be improved. Given generator-to-generator
blindness it is the correct design: the one mechanism that cannot miss a TDLib vector.
Telegram/Td/Vector.cs holds both types.
Vector<T> : IReadOnlyList<T>- no mutating member at all, so a write is a compile error rather than a runtime throw, and passing one where anIList<T>is expected does not compile either. That last part is what made the escape points enumerable.MutableVector<T> : Vector<T>, IList<T>, IList- for building a request argument, and for the reused-buffer case (ChatView._viewVisibleMessagesintoViewMessages, refilled per scroll) where a copy per call would be a real regression.Client.Sendserialises synchronously, so the callee cannot observe a later edit.- Not sealed, so
Countcomes from a_countfield rather than the array length. Costs one int - 24 to 32 bytes an instance, parity withList<T>- and one compare on the indexer. Measured. Vector<T>.Emptyper element type;Array.Empty<T>()and[]both fold to it. The implicit conversion fromT[]wraps without copying, so ~134 construction sites compiled unchanged.- The name has to stay in the root
Telegramnamespace:System.Numerics.Vector<T>clashes, generic using aliases are not legal C# (verified), and an enclosing namespace beats a using.
TdVectorStats, one minute of use, before the change:
vectors parsed : 35,424
empty : 25,361 (71.6 %) = 792 KB of List<T> holding nothing
ChatPosition 5,198/5,213 empty 162 KB 0 mutation sites
Int32 4,866/6,162 152 KB unattributed
String 3,794/5,879 118 KB unattributed
TextEntity 3,559/4,423 111 KB 0 after FormattedTextBuilder
UnreadReaction 3,299/3,300 103 KB 6 sites
ChatList 2,709/2,713 84 KB 2 sites
Int32/String are 270 KB the element-type granularity cannot attribute - the parser is generic
over the element and never learns the field. Adding the field name to the reader signatures would
close it.
TdVectorStats.Countwas lost from all nine readers in the rewrite. There is a before snapshot and no after. Restore the calls before the tree drifts further.CsWinRT.Vectors.csneeds both halves reworked. Of its 182List<TdType>registrations, 23 are still live (app code that builds its ownList<Chat>and binds it) and 159 are dead. TheVector<X>set that replaces them does not exist yet. Deciding it needs the ItemsSource question below answered first.ItemsSourcebinding is untested.Vector<T>no longer implements non-genericIList, which is whatIBindableVectorprojects from. Non-genericIEnumerablesurvives viaIEnumerable<T>, soIBindableIterableshould be enough - but open an Instant View and a bound TDLib list before believing it. If it is not, the fallback isVector<T> : IReadOnlyList<T>, IList, which still makes everyIList<T>escape a compile error.MutableVector<T>escapes into TDLib objects.MarkdownToInstantViewandRichHtmlbuildPageBlocktrees whoseBlocks/Items/Cellsare mutable instances. Finishing those builders with.ToVector()would keep the registered set to one type per element instead of two.SettingsStorageViewModel:result.ByFileType.Add(already)becamechatByFileType.Add(...)during the port, so the aggregate is now never populated. Looks like a rename applied one identifier too far.- The Reader parser family is ported but never compiled.
TdParsers=Readershould build again; it has not been tried. Naming.csmust land with the migration, not before. Alone it emitsVector<T>against consumers that still expectIList<T>.
- A reader's empty guard and its loop condition must agree.
GetStringArrayPtrguarded onStringand looped onNumber, so a two-element string array parsed one element and left the reader mid-array. It surfaced asupdateChatLastMessage.Positionsbeing null - four levels up, a different field, no exception anywhere. Cross-check the nine pairs after any parser edit. is Vector<T>no longer means immutable, sinceMutableVector<T>satisfies it.ToVector()and theVector(IEnumerable<T>)fast path both checkGetType() == typeof(Vector<T>)before sharing an array. Anything else doing a type test needs the same care.Empty()andFindIndex()-style helpers: anICollection<T>and anIReadOnlyCollection<T>overload pair is unfixable, becauseList<T>, arrays andMutableVector<T>implement both and neither converts to the other. OneIEnumerable<T>overload with type tests, plus an exactVector<T>overload for the fast path - that pair is asymmetric, so it resolves.