Skip to content

Fix the parts of #2253 that still reproduce - #4108

Merged
siegfriedpammer merged 6 commits into
masterfrom
fix/2253-baml-project-export
Sep 5, 2026
Merged

siegfriedpammer merged 6 commits into
masterfrom
fix/2253-baml-project-export

Conversation

@siegfriedpammer

@siegfriedpammer siegfriedpammer commented Sep 4, 2026

Copy link
Copy Markdown
Member

Works through the parts of #2253 that still reproduced on master, after every claim in the thread
was rebuilt and re-tested. Five of the twelve were already fixed, three are fixed here, one is
closed as not worth its cost, and one turns out not to reproduce. Verifying the result against a
real WPF application then turned up a defect nobody had reported.

-p converts BAML on its own

A WPF assembly keeps its windows and pages as BAML, so a project exported without converting them
back is missing the parts that make it a WPF application. The CLI could do it since
--decompile-baml was added, but only if asked - which meant everything the exporter knows about
WPF (Page and ApplicationDefinition items, resources, generated members removed from the
code-behind) was invisible to anyone following #2253 from the command line. The flag is kept and
ignored, because it is documented and scripted against.

A XAML parser can read the output back

Markup-extension values (claim 9) went out unquoted, so an argument carrying , = { } a
quote or a backslash was read back as further name/value pairs. This is the DevExpress
{DXBinding Expr='...'} report, and it is the only claim of the twelve whose output does not
compile. Values without such a character stay unquoted, so documents that never needed quoting are
unchanged.

Namespace declarations (claim 11) were minted on the element that used them, for namespaces the
document had already declared at its root. Two causes: a clr-namespace: declaration names the CLR
namespace it maps and nothing read that name out of the URI, so no lookup by namespace could match
the declaration the document made; and the document records the assembly it was written against
while a well-known type carries the assembly it resolves to now - mscorlib against
System.Private.CoreLib - so those are now accepted as the same when the recorded assembly
forwards the type.

A document and its code-behind share a path (claims 2 and 7)

The XAML went to the project root under a fully-qualified name while the partial class went into a
namespace directory, so the two halves of one class sat in different places and WPF tooling paired
neither; --nested-directories moved only the C# half, widening the split. Both now go through one
function that decides where a type's files live. The BAML writers of the UI and of the command line
had each grown their own copy of the naming, which is how they came to disagree with the C# writer.

What an exported application was still missing

StartupUri is written in App.xaml but never reaches the BAML: the markup compiler turns the
attribute into an assignment inside InitializeComponent, and the project decompiler deletes the
generated members, so the exported application built and opened no window. The assignment is read
back out of the generated method and written to the document root again. Where the compiler emits
no app.baml at all - an App.xaml carrying nothing but attributes - there is no document to write
it on, and the export has no ApplicationDefinition either; that is a larger gap of its own.

A CLR property called Name on a type of the assembly being decompiled was written back as the
x:Name directive. x:Name is recorded as the runtime name property of an element, which is
FrameworkElement.Name for everything WPF - a property of the framework. <local:Helper Name="h" />
therefore came back as x:Name="h": the name gets registered, the property stays unset, and it
still compiles. The directive is now written only for a name the element does not declare itself.

Two things found while verifying

A facade standing in for WindowsBase cost every WPF assembly its geometry documents. .NET ships
a WindowsBase facade on every platform: it resolves under the name BAML means, so no synthetic
stand-in was substituted, and it carries none of the WPF types because those live in the
WindowsDesktop runtime pack. System.Windows.Point and Size then resolved to nothing and the
whole resource was lost to a NullReferenceException. Ten of the BAML entries in one DevExpress
theme assembly, on every machine without WPF - which is every Linux and macOS user and every CI
run. A well-known assembly now counts as resolved only if it defines a type it is expected to have.

Item metadata in the non-SDK project format was written as attributes, which is MSBuild 15
syntax; that format exists for the toolchains that predate it. It now writes child elements, the way
every non-SDK project keeps metadata. The SDK-style writer keeps attributes.

Verification

  • 30 tests in ILSpy.BamlDecompiler.Tests and 5 in ICSharpCode.ILSpyCmd.Tests, each written
    failing first. The quoting rule has nine cases; the xmlns and facade tests reproduce their
    defects from BAML records, so they need no WPF and run everywhere.
  • A real DevExpress theme assembly (1168 BAML documents, from the package on nuget.org):
    output identical to master over all 1158 documents master could produce, plus the ten it used to
    lose to the facade bug.
  • The application from the report. DevExpress publishes the source of
    DevExpress.StockMarketTrader.Wpf and the components it builds against are on nuget.org, so it
    can be built and its BAML compared against the XAML it came from:
    {DXBinding Expr='ThemeIndex == 0', BackExpr='0'} came back from master as
    Expr=ThemeIndex == 0, BackExpr=0 - two name/value pairs where the author wrote one - and comes
    back correct here. Its App.xaml also gets its StartupUri back, byte-identical to the source.
  • A purpose-built WPF application for the two cases the demo does not cover: a class with a CLR
    property of its own called Name, and an App.xaml with a StartupUri. Both round-trip identically
    to the source now; before, one lost its property to the directive and the other lost the attribute
    entirely.
  • ICSharpCode.Decompiler.Tests 3599, ILSpy.Tests 1262, ICSharpCode.ILSpyCmd.Tests 47, no
    failures.

Deliberately not done

  • Attribute wrapping (claim 12). XmlWriterSettings.NewLineOnAttributes would turn this on in
    one line, but it applies to every element, so <TextBlock Text="a" /> becomes three lines too.
    Wrapping only long start tags means hand-writing the serializer - XmlWriter closes the start tag
    as soon as whitespace is written between attributes - and a hand-written writer owns attribute
    escaping, which is where this decompiler has had real bugs. Not worth it for a cosmetic
    complaint.
  • Generator (claim 8). The reported replacement, XamlIntelliSenseFileGenerator, is wrong in
    both project formats: real projects of either kind write MSBuild:Compile.
  • A nested tag as an attribute (claim 10). Does not reproduce. Across the twelve documents of
    the application it was reported against, no element is lost; what is there is a declaring-type
    shift (UserControl.Resources becomes FrameworkElement.Resources), which is legal and compiles.
  • Reconstructing an App.xaml that was never compiled into BAML. When the markup compiler emits
    no app.baml, the export has no application definition to produce, StartupUri included. That is a
    bigger piece of work than recovering an attribute onto a document that exists.

Prepared by an AI agent (Claude, claude-opus-5, via Claude Code) and reviewed by @siegfriedpammer.

A WPF assembly keeps its windows and pages as BAML, so a project exported
without converting them back is missing the parts that make it a WPF
application - and the reader has no XAML to look at either. The CLI could
do the conversion since --decompile-baml was added, but only if asked,
which meant that everything the project exporter learned about WPF (Page
and ApplicationDefinition items, resources, generated members removed
from the code-behind) was invisible to anyone following issue #2253 from
the command line.

The flag is kept and ignored: it is documented and scripted against, and
asking for what is now the default has to keep working.

The test fixture is a real .g.resources container rather than a directly
embedded .baml stream, because only entries inside a container reach
WriteResourceToFile - a standalone .baml is copied out untouched, which
is worth its own look.

Assisted-by: Claude:claude-opus-5:Claude Code
Two of the defects reported on issue #2253 come from the decompiler
writing text that means something else when it is read again.

A markup extension is written as a single attribute value, and its
grammar gives ',' '=' '{' '}' and the quote characters a meaning. Values
went out unquoted, so an argument carrying any of them was read back as
further name/value pairs: {DXBinding Expr='Price - Prev > 0 ? ...'}, the
reported case, no longer compiles at all (MC3042, MC3045). Values without
such a character stay unquoted, because quoting them would rewrite every
document that never needed it.

A clr-namespace declaration names the CLR namespace it maps, but nothing
read that name out of it, so no lookup by namespace could match a
declaration the document itself had made. Every type in such a namespace
then got a second prefix declared on the element that used it. The
assembly is the second half of the same lookup, and there the document
records the name it was written against while a well-known type carries
the assembly it resolves to now - "mscorlib" against
"System.Private.CoreLib" - so the two are also accepted as the same when
the recorded assembly forwards the type.

Assisted-by: Claude:claude-opus-5:Claude Code
The project exporter wrote every XAML document to the project root under
a fully-qualified name while the code-behind class went into a directory
named after its namespace, so the two halves of one partial class ended
up in different places. WPF tooling pairs MainWindow.xaml with
MainWindow.xaml.cs by name and location; anything else is an unrelated
file to it, and --nested-directories made the split wider still by moving
only the C# half.

Both now go through one function that decides where a type's files live,
so the document lands where the type's own C# file would have, and the
code-behind is named after the document. The BAML writers of the UI and
of the command line had grown their own copies of the naming, which is
how they came to disagree with the C# writer in the first place.

Assisted-by: Claude:claude-opus-5:Claude Code
Metadata as attributes on an item element is MSBuild 15 syntax. The
non-SDK project format is what an export falls back to for toolchains
that predate the SDK, and those reject an unknown attribute on an item
element, so a Page item carrying Generator and SubType as attributes
undoes the reason to write that format at all. Every non-SDK project
written by anything else keeps metadata in child elements.

The SDK-style writer keeps attributes: there the syntax is a given and
it is what the format's own tooling produces.

Assisted-by: Claude:claude-opus-5:Claude Code
.NET ships a WindowsBase facade on every platform. It resolves under the
name BAML means, so no synthetic stand-in was substituted for it, and it
carries none of the WPF types, because those live in the WindowsDesktop
runtime pack. System.Windows.Point and Size then resolved to nothing and
the whole resource was lost with a NullReferenceException - ten of the
BAML entries in one DevExpress theme assembly, on any machine without
WPF, which is every Linux and macOS user and every CI run.

A well-known assembly now counts as resolved only if it defines a type it
is expected to have, so a facade gives way to the stand-in the way an
assembly that does not resolve at all does. Nothing else changes: over
1158 documents of that assembly the output is identical, with the ten
that used to be lost added back.

Assisted-by: Claude:claude-opus-5:Claude Code
Two things #2253 costs an exported application, both found by decompiling
one that was built from published source.

StartupUri is written in App.xaml but never reaches the BAML: the markup
compiler turns the attribute into an assignment inside
InitializeComponent, and the project decompiler deletes the generated
members. The exported application then builds and opens no window. The
assignment is read back out of the generated method and written to the
document root again. Where the compiler emits no app.baml at all -
App.xaml carrying nothing but attributes - there is no document to write
it on, and the export has no ApplicationDefinition either; that is a
larger gap of its own.

x:Name is recorded as the runtime name property of an element, which is
FrameworkElement.Name for everything WPF - a property of the framework.
Any property called "Name" on a type of the assembly being decompiled was
written back as the directive, so <local:Helper Name="theName" /> came
back as x:Name: the name gets registered and the property stays unset,
which still compiles and quietly means something else. The directive is
now written only for a name the element does not declare itself.

Assisted-by: Claude:claude-opus-5:Claude Code
@siegfriedpammer
siegfriedpammer force-pushed the fix/2253-baml-project-export branch from d2df2a6 to 8747055 Compare September 4, 2026 17:42
@siegfriedpammer
siegfriedpammer merged commit 0ed559a into master Sep 5, 2026
15 checks passed
@siegfriedpammer
siegfriedpammer deleted the fix/2253-baml-project-export branch September 5, 2026 07:40
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