Skip to content

Managed ilasm: record sequence points as instructions are emitted, on the lines of the .il source when no .line directive is in effect - #135312

Open
pcshrosbree wants to merge 20 commits into
dotnet:mainfrom
pcshrosbree:ilasm-goodwill/implicit-sequence-points
Open

pcshrosbree wants to merge 20 commits into
dotnet:mainfrom
pcshrosbree:ilasm-goodwill/implicit-sequence-points

Conversation

@pcshrosbree

@pcshrosbree pcshrosbree commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Part of making the managed ilasm (src/tools/ilasm) a drop-in replacement for native ilasm, as discussed in #135212. There @jkotas pointed to the managed rewrite as the way forward, @jkoritzinsky confirmed that PDB differences from native ilasm are unintended, and @am11 suggested combining efforts.

Depends on #135311 (local scopes), which depends on #135297 (PDB documents) and #135289 (the separate .pdb file). This branch is stacked on all three: the first eighteen commits here are theirs and will drop out of this diff as they merge. The two commits of this PR are the last two: the change with its unit and generated tests, and the command-line tests.

With -DEBUG (in any mode) or -PDB, native ilasm makes the .il file itself debuggable: when it emits an instruction that no .line directive covers, it records a sequence point on that instruction's line of the .il source, columns 1 to 2 (SetupInstr, EmitOpcode). The managed ilasm recorded points only when a .line directive was applied, which gives these differences:

  1. No points for the .il source. Before: a .il file without .line directives had a PDB with no sequence points, so it could not be stepped through; TestLocalScopes4.il had 0 points against native's 36. After, as in native ilasm: each instruction is mapped when it is emitted, to its own line of the .il file it is in, unless a .line is in effect. An instruction gets a point only when its coordinates differ from the last point's, so instructions on one line share one. The first instruction after a directive always gets a point.
  2. A .line after a method's last instruction. Before: a point at the method's end offset. After: no point in that method; the directive applies to the next instruction, as in native.
  3. A .line at class or top level. Before: its file became the current document, but its line and columns were dropped. After: the next instruction, the first of the next method, gets them.
  4. #included files. Before: an included file was not a document, and a file named by a .line inside it stayed current after the include. After: instructions in an included file are on its own lines, in a document named by its full path. An included file starts without a .line, and its .line does not apply after it.

An instruction with a malformed operand emits nothing and records no point. A point recorded at the offset of the previous one replaces it, so the sequence points blob never holds two points at one offset, which a reader would misread as a document change.

Behaviour changes to be aware of:

  • Every PDB of a .il file that is not fully covered by .line directives now has a point per instruction line. TestLocalScopes4.il's PDB grows from 548 to 732 bytes (native: 736). With --deterministic, the PDB, and through its id the image, now also depend on the .il file's line layout.
  • As in native ilasm, a .line stays in effect to the end of its file, across methods, and the last point is compared across methods. A method without a .line of its own, after one with a .line, maps to that directive's coordinates, and gets no point at all while that directive's span is the last point's. That is the shape ildasm writes for a method that had no sequence points, when it writes .line directives at all. I could not run that round trip here: ildasm on Linux writes no .line directives.
  • The CLI names included files by their full path, as native ilasm does for the PDB; error messages in included files now show the full path too.

Deliberate differences from native, five of them listed in MANAGED-ILASM-FIXES.md:

  • An instruction gets a point when its document differs from the last point's, not only its lines and columns. Native gives no point to an instruction in another file that is on the line of the last point, and so attributes it to the previous file.
  • After an #include, the including file keeps its .line directive and that directive's file. Native makes the including .il the current file again but keeps the directive's line and columns, which then point into the .il file.
  • An included file is a document only once one of its instructions has a point. Native defines every included file when it is included.
  • An included file's document has the language in effect at its first point. Native always gives it the IL assembly language, because it defines the document at the #include before a .language just before it has been applied.
  • An instruction produced by a #define macro is on the line where the macro is used. Native reads the macro's text as a new source starting at line 1, so such instructions are mapped to line 1 and share one point.

A method that ends up with no sequence points keeps a nil Document, as in the document change (#135297) and as the spec says; native names the current document. With the cross-method rule above this is now a common shape.

Other differences from native ilasm found in the same survey, not taken by any of these PRs, are tracked in #135314.

Not in this PR: #line's line auto-increment (parsed, not applied; native's own implementation is broken); end-column defaults for a partly specified .line (#46328); validation of sequence points, including 0xFEEFEE start lines without 0,0 columns, which this change treats as hidden and native rejects.

Questions:

  1. Native's rule that a .line lasts to the end of the file across methods, so that a later method without its own .line may get no point, is kept for parity. Would you rather reset per method, so that every method's first instruction gets a point?
  2. Is the PDB growth acceptable as the default, as in native ilasm?

Testing:

  • Unit tests: 39 new rows in SequencePointTests, read back with System.Reflection.Metadata:
    • one point per instruction line on the input document, columns 1 to 2, and one shared point for instructions on one line;
    • the same points with each switch that produces a PDB;
    • a .line before the first instruction or in the middle of a body; the instruction after a .line getting a point even with the same coordinates; a .line after the last instruction and a class-level .line applying to the next method; a later method without a .line getting no point, as in native ilasm; hidden .line;
    • a method mapped to the .il and then to a .line file having a nil Document and the .il as InitialDocument;
    • instructions in an included file, the included document and its language, an included file without a point of its own, and .line not crossing an include in either direction;
    • instructions produced by #define macros;
    • a second input file, and an instruction on the last point's line in another file;
    • /OPTIMIZE offsets and /DET identical PDBs;
    • an instruction with a malformed operand, for each of the six kinds of reference operand (method, field, metadata token, type, calli signature, ldtoken owner), both followed by another instruction and at the end of a body.
    • PdbOption_ProducesPortablePdbWithoutLineDirectives now also checks the points of a -PDB compile without .line.
    • I broke each rule in turn and checked that a test fails, including the malformed-operand check in each of the six emitters separately. The changes no test notices are allocation-only (recording points without a PDB switch; keeping the previous input file's per-file state), and dropping the same-offset replacement while all six checks are in place, which is then unreachable.
  • Generated cases: the 200 seeded programs now place each one- or two-byte instruction on a known line, with blank and comment lines, several instructions on one line, .line directives before instructions, after the last one and at class level, methods without directives, and whole methods or runs of instructions in included files. The model computes every point (offset, lines, columns, document) from the source it writes; it follows native ilasm's rules with the differences listed above. Six theories (1,200 rows) compare the PDB with it, and a seventh check ensures each of 14 shapes occurs at least 20 times.
  • Command-line tests: CommandLineSequencePointTests in ILAssembler.Tests runs the ilasm command line in process with -DEBUG and reads the PDB it writes, end to end. It covers an input without .line, given by a path relative to the current directory (written under it, so the argument is never rooted, without setting the current directory) and by a full path with a .. segment: points on each instruction's own line, columns 1 to 2, two instructions on one line sharing a point, all in a document named by the input's full path. It covers a .line after an implicit point, so the method spans the input's document and the named file with a nil Document. And it covers an #included file given by a path relative to the current directory: its instructions are in a document named by the included file's full path, and the including file's later instructions are back in the input's document. I broke each of these rules in turn and checked that the test fails.
  • Before moving these cases into ILAssembler.Tests, I measured them as two extra cases in the runtime's PortablePdb tests (src/tests/ilasm), run against both ilasms on Linux x64, out of tree. All 65 rows of that run, native and managed cases together, passed; with the managed ilasm at Managed ilasm: honour explicit local slots, scope local names lexically, and emit LocalScope/LocalVariable rows #135311's head before this change, the case without .line directives failed. Those cases are not part of this PR.
  • I also compared PDBs from native ilasm and from this change for NoLine, MixedLine and EmptyDocName inputs and for TestLocalScopes4, TestMethodDebugInformation, TestDocuments1 and TestImplicitLines1. Documents, method documents and every sequence point are identical. On other inputs, the only differences I found are the ones listed above, including the nil Document of methods without points.

Note

This change was prepared with AI assistance (Anthropic Claude and OpenAI Codex) under my direction. AI agents wrote the code and ran the build and tests on my machine. I reviewed the change before posting.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 5 pipeline(s).
11 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @JulieLeeMSFT, @dotnet/jit-contrib
See info in area-owners.md if you want to be subscribed.

With /DEBUG or /PDB, the managed ilasm embedded the Portable PDB in the
image and named "assembly.pdb" in the CodeView entry. Native ilasm writes
the PDB as a separate file named after the output, and records the full
path of that file in the CodeView entry.

Match native ilasm:

- The assembler returns the serialized PDB in CompilationResult.PortablePdb
  instead of embedding it, and the CLI writes it to <output>.pdb beside
  the output.
- The CodeView entry names the PDB path given in the new
  Options.PdbFilePath. The CLI passes the full path of <output>.pdb.
  Library callers that do not set it get OutputFileName with its
  extension replaced by .pdb, or assembly.pdb when there is no output
  file name.
- A PdbChecksum entry follows the CodeView entry. It holds the SHA-256
  hash of the PDB with its 20-byte id zeroed, which is the hash the
  PortablePdbBuilder id provider computes, so the deterministic id and
  the checksum come from one hash.
- With /DET, a Reproducible entry follows, as native ilasm and the C#
  compiler write it.
- An image built without a PDB has no debug directory. ManagedPEBuilder
  adds a Reproducible-only directory to a deterministic image when it is
  given none; the new ILAssemblerPEBuilder suppresses that, for the
  standard and the vtfixup/export builders.

The CLI's file output moves into OutputWriter, which the tests compile:

- The image is written and closed first. The PDB is then written to a
  new temporary file in the same directory and renamed over
  <output>.pdb, so <output>.pdb never holds a partial PDB.
- When the image is written without a PDB, an existing <output>.pdb is
  deleted only if it belongs to the image that was replaced: that image
  names the PDB's id in its CodeView entry. Any other file is kept. When
  the assembly fails and produces no image, nothing is written or
  deleted; with /ERR, an image written despite errors is handled as after
  a successful assembly. Native ilasm deletes <output>.PDB whether or not
  it belongs to the replaced image, and also when the assembly fails, and
  on Linux that is a different file from .pdb; that can remove a PDB that
  ilasm did not write, so it is not reproduced.
- An output named like its PDB (-OUTPUT=Min.pdb with /DEBUG) is refused
  instead of being overwritten by the PDB.

/PDB still produces only the PDB: it adds no DebuggableAttribute and
leaves the JIT settings unchanged. Tests now pin that.

The README documents the PDB behaviour, and the doc comments state the
DebuggableAttribute modes correctly: /DEBUG=IMPL is 0x103, Default |
IgnoreSymbolStoreSequencePoints | DisableOptimizations, and does not
enable Edit and Continue.

Tests: the unit tests read the PDB from the compilation result and pin
the debug directory entries and their order, the CodeView path and id,
the checksum, deterministic output and the output writer's file
handling, including which PDB it deletes and that it closes the image
before touching the PDB. Seeded theories check the same rules over
generated programs and option combinations, and the output writer over
an enumeration of output names and pre-existing output and PDB states.
src/tests/ilasm/PortablePdb/IlasmPdbFileTester.cs runs native and
managed ilasm end to end. Its managed cases are conditional on
CORE_ROOT having the managed ilasm, which is built only where the SDK
tools are; cases native ilasm does not satisfy run against the managed
ilasm only.
The managed ilasm produced a PDB whenever the source had .line
directives, even without /DEBUG or /PDB. Native ilasm produces a PDB only
when one of those switches is given.

Match native ilasm: a PDB is produced only with --debug, --debug-mode or
--pdb. Without them the image has no debug directory, with or without
--deterministic, and the .line directives are still parsed and validated
but their sequence points are not emitted. --debug-mode alone counts as
--debug, as it already did for the DebuggableAttribute.

Tests that relied on the implicit PDB now pass Debug = true. New unit
tests pin that .line alone produces no PDB, that --debug-mode alone
produces one, and that an image without a PDB has no debug directory and
no debug data, deterministic or not, with or without .line. Seeded
theories over generated programs and options pin that a PDB is produced
exactly when a switch requests it and that an image without one has no
debug directory and no debug data. IlasmPdbFileTester checks both ilasms
end to end without a switch, with and without .line and /DET (the
managed ilasm when CORE_ROOT has it). The README and the doc comments
say which switches produce a PDB.
…rip harness

The "Test PDB determinism" step assembled with -DEBUG to -output=<name>.pdb
and hashed that file. With native ilasm the PDB is <output without
extension>.pdb, the same path, so the PDB overwrote the image and the
step hashed the PDB. With the previous managed ilasm the step hashed the
image, which had the PDB embedded, so it also checked that the image
assembled with -DEBUG repeats.

The managed ilasm now writes the PDB beside the output and refuses an
output path the PDB would overwrite, so the step failed with "ILASM
failed with exit code 1" in the managedilasmroundtrip scenario.

Assemble to IL-RT/<name>.pdbcheck.dll instead and hash both that image
and the PDB that ilasm writes beside it, IL-RT/<name>.pdbcheck.pdb, and
fail the step if either differs between the two runs. Native and managed
ilasm both write that pair, so the step checks the -DEBUG image and the
PDB for both again. The step also no longer overwrites the test's own
<name>.pdb, the C# compiler's PDB, in the test directory. The rest of the
round trip is unchanged.
… path-based type in ilasm

OutputWriter moves to the ILAssembler library and works on streams only: it
writes and closes the image, then writes the PDB, or, when no PDB was
produced, deletes the existing PDB only if the replaced image's CodeView
entry names its id. It refuses to write a PDB over the output. The caller
supplies the output and the PDB through the new IOutputStreams interface,
so every decision can be tested with in-memory streams.

OutputFileWriter in the ilasm tool backs those streams with files: it
resolves <output>.pdb, opens the existing image and PDB, creates the
output, writes the PDB to a temporary file in the same directory and
renames it into place, and deletes a stale PDB. Program calls it. The
behaviour of the tool is unchanged.

The tests that need no file system now run on streams in
OutputWriterTests; OutputFileWriterTests keeps the path, temporary file,
rename, deletion and same-file tests, and the seeded output-writer
theories run against OutputFileWriter.
The end-to-end PDB tests that ran the managed ilasm as a process from
src/tests/ilasm now run the command line in process in ILAssembler.Tests
(CommandLinePdbFileTests): Program.cs is linked into the test project, as
the command-line parsing already is, and each test assembles inline IL
sources into its own temporary directory. They cover the PDB beside the
output and its CodeView entry, the debug directory order, the checksum,
no PDB without a switch (with and without .line and -DET), the
DebuggableAttribute, stale-PDB replacement and deletion, an unrelated PDB
kept, failed builds, -ERR, the refusal of an output named like its PDB,
and byte-identical -DET repeats.

The rows that ran native ilasm as a control are dropped; native ilasm is
not part of ILAssembler.Tests. src/tests/ilasm is back to what it was
before this change.
… PDB ids

The IOutputStreams remarks now say exactly when OutputWriter.Write calls
each member: OpenExistingOutput only when no PDB is produced and the PDB
path is not the output path; CreateOutput unless the PDB would overwrite
the output; WritePdb when a PDB is produced; otherwise OpenExistingPdb only
when the replaced output yielded a PDB id, and TryDeletePdb only when that
id equals the existing PDB's id.

The stream test that keeps a PDB the replaced image does not reference
again asserts its precondition, which the file-based test had before the
split: the replaced image's CodeView entry and the PDB at the PDB path
carry different ids. The ids are read with PEReader and
MetadataReaderProvider rather than the writer's own readers. The two
pairs differ by construction: they are compiled deterministically from
different sources, and a deterministic PDB id is a hash of the PDB.
…ests that cannot run as skipped

Review feedback on the output file writer:

- IsOutputPath compared the output and PDB paths as strings, so an output
  that is a symbolic link to <output>.pdb was not recognized: the image was
  written through the link into the PDB's file, the PDB was then renamed over
  it, and the image was lost although the run reported success. IsOutputPath
  now also follows the chain of symbolic links that starts at the output path
  (relative targets against the link's directory, dangling targets included,
  at most 40 links) and compares each target with the PDB path, so such an
  output gets the existing "would overwrite the output file" refusal. A
  missing path counts as no link; any other failure to read a link
  propagates, so an output that cannot be inspected is not written. A link at
  the PDB path and a hard link between the two names need no check, because
  the PDB is renamed into place rather than written through either name;
  tests pin both.

- The rename test returned early on Windows under a plain [Fact], so it was
  reported as passed there without running. It now uses [ConditionalFact] and
  is reported as skipped on Windows. The new symbolic-link tests are
  conditioned on whether the process can create a symbolic link, so they run
  on Windows where that is permitted and are reported as skipped otherwise;
  the hard-link test runs everywhere. The test project references
  Microsoft.DotNet.XUnitExtensions for the attribute, as the coreclr tool
  test projects do.
The review asked that a deterministic build not record the PDB's full
path, in the way the native linker's /PDBALTPATH:%_PDB% does, and that a
non-deterministic build keep it. The image's CodeView entry now names
the PDB's file name and extension (Min.pdb) with --deterministic, and
its full path otherwise. The PDB file is still written beside the output
in both modes. Native ilasm records the full path in both modes, so the
deterministic rule is a deliberate difference from it.

So a deterministic image no longer depends on the directory it is
written to: the same input assembled to two directories gives
byte-identical images, as it already gave byte-identical PDBs. The
command line makes the choice, because it is the one place that knows
the PDB's full path; the assembler records Options.PdbFilePath as given.

New command-line tests pin, under -DET: the file name with -DEBUG and
-PDB; the file name for a relative output path (reported as skipped
where no relative path reaches the temporary directory); that
System.Reflection.Metadata finds the PDB beside the image from that
name, with the matching id; and identical images and PDBs across two
output directories. The existing full-path test now states that it
covers builds without -DET. The README and the doc comments state what
is recorded in each mode.
The README is for build instructions, not for the tool's features, so this
restores it to main's text. The behaviour it described is stated in the doc
comments of the options, which are unchanged.
…an several documents

The managed ilasm kept one document per method: the file named by the first
.line directive of the method. A method whose .line directives named two
files had all of its sequence points in the first one, and an empty file
name ('' or "") created a document named ''.

Each sequence point now records the document that is current when its
directive is applied, as an index into the compilation's PDB document
table. When the points of a method span several documents, its
MethodDebugInformation row has a nil Document, the sequence points blob
starts with the InitialDocument (the document of the first point), and a
document-record precedes each non-hidden point whose document differs from
the current one. Hidden points take the current document. This is the
encoding native ilasm writes (portable_pdb.cpp, DefineSequencePoints).
A method whose points are all in one document still names that document
and has no InitialDocument.

An empty file name leaves the current document unchanged, as in native
ilasm (SetSourceFileName ignores an empty name). ildasm writes '' for "the
same file as the previous directive", including on the first .line of a
method whose file did not change.

A directive at the same IL offset as the previous one still replaces that
point; the replacement now also takes the last directive's document.

A method without an IL body (abstract, pinvokeimpl, runtime-implemented)
gets a MethodDebugInformation row with a nil Document and no sequence
points blob, even when it contains a .line directive. Before, such a method
got a point at IL offset 0 of a body it does not have; native ilasm records
sequence points as it emits instructions, so it records none.
MethodDefinitionEntity.HasBody is the one test for "has a body" that both
the IL writer and the PDB writer use.

Tests: PdbDocumentTests reads the PDB back with System.Reflection.Metadata
and checks the nil Document, the InitialDocument, the document of each
point (including A, B, A switching and hidden points, with the blob's raw
records showing that no document-record is written for a hidden point),
'' within and at the start of a method, that a method without points keeps
a nil Document and no blob, that abstract and pinvokeimpl methods with a
.line directive get no points, nor does a body with directives but no
instructions, and that in an error-tolerant compile a method that still
has its instructions keeps its points.
The managed ilasm created a PDB document only for a file that a method's
first sequence point used, in MethodDef order, keyed by name and language,
and with no language unless .language was given. The input .il file was
never a document.

Native ilasm defines documents as it meets them: the input file, by full
path, before parsing it (main.cpp), and each file named by .line or #line
when the directive is applied, at top level, in a class or in a method,
whether or not a sequence point ever uses it (SetSourceFileName,
DefineDocument). A document is identified by its name alone and keeps the
language current at its first definition. The language defaults to IL
assembly, af046cd3-d0e1-11d2-977c-00a0c9b4d50c
(CorSym_LanguageType_ILAssembly).

The managed ilasm now does the same:

- BeginDocument defines each input file as a document and makes it the
  current document, so a .line without a file name before any named file
  is in the input file, and each input file starts in its own document.
- A .line or #line with a non-empty file name defines that file and makes
  it current wherever the directive appears.
- The document table keeps documents in encounter order, keyed by name;
  the PDB lists them in that order.
- Documents defined before any .language directive have the IL assembly
  language; .language applies to documents defined after it.
- The CLI passes the full path of each input file, which is the input
  document's name. Library callers get SourceText.Path as given. Error
  messages from the CLI now name the input file by its full path too.

Existing expectations that change because the input file is now a
document, each now asserting the complete ordered list of document names
(input file first) where it asserted a count or a name: SourceDirectiveTests
(PdbGeneration_WithLineAndLanguageDirectives_CreatesValidPdb,
PdbGeneration_LanguageWithVendorAndDocumentType_CreatesValidPdb,
LanguageDirective_SyntaxVariant_EmitsDocumentLanguage,
ClassScopedLanguageAndLineDirectives_ApplyToMethodSequencePoint,
MultiDocumentCompile_WithLineDirectivesAcrossDocuments_EmitsPdbDocumentsForEachSource,
MalformedLanguageDirective_DoesNotPartiallyUpdateGuidState),
CompilerOptionsTests.PdbOption_ProducesPortablePdbWithoutLineDirectives
(one document, the input file, instead of none) and
ModuleTests.GlobalMethodAndModuleAttribute_EmitModuleMetadataAndPdb.
MultiDocumentCompile_DoesNotReusePreviousDocumentPath now expects the
second file's .line without a file name to be a point in the second input
file instead of no point at all, and checks the three documents; it still
checks that the first file's .line file is not reused.

Tests: PdbDocumentTests checks the input file as the first document, the
encounter order (not MethodDef order), files named at top level, class
level or by a replaced directive, the default language, .language for
later documents and for documents of the next input file, a file named
again keeping its first language, names that differ only in case being
different documents (native compares with strcmp), one document per input
file, and identical PDB bytes for a deterministic multi-document program.
PdbCaseGenerator generates 200 seeded programs, in one input file or two,
whose methods (some of them abstract) move between a few documents with
named, '', unnamed and hidden .line directives in the full form native
ilasm accepts, class-level directives and .language, and computes the
expected documents and points; PdbGeneratedCaseTests checks each point's
document, the method's Document, the InitialDocument and the document
table against it.
The sequence points blob starts with LocalSignature, which the Portable PDB
spec (docs/design/specs/PortablePdb-Metadata.md, MethodDebugInformation
Table) defines as the row id of the method's local signature in the
StandAloneSig table. The managed ilasm wrote 0 for every method; native
ilasm writes RidFromToken(m_LocalsSig).

When EntityRegistry writes a method body it now records the local
signature handle that the body references in the method's debug
information, and EncodeSequencePoints writes its row id, or 0 when the
body has no locals. With /FOLD, a method whose body is shared with an
earlier one gets the same handle, because the shared body's bytes include
the local signature token.

Tests: PdbDocumentTests checks the row id for two methods with different
local signatures (rows 1 and 2), 0 for a method without locals, the row
id of methods whose bodies /FOLD shares, and that a method with locals but
no .line directive has no blob at all. A seeded theory checks that the
blob's LocalSignature equals the body's for every generated method with
sequence points, and that abstract methods have no body.
CommandLinePdbDocumentTests runs the ilasm command line in process, as
CommandLinePdbFileTests does, and reads the image and PDB it writes. It
covers what the command line decides and the library tests cannot see:

- Without any .line directive, the PDB has one document: the input file,
  named by its full path.
- An input given by a path relative to the current directory is named by
  the full path it resolves to. The test reads the current directory and
  never sets it.
- Two input files are two documents, by full path, in command-line order,
  before the document of a .line directive in the second.
- The TestDocuments2 case of the runtime's PortablePdb tests: '' on a
  method's first .line keeps the previous method's document, so both
  methods' points are in the named file and the second method does not
  span documents.

PortablePdbTestReader gets an Open(imagePath, pdbPath) factory beside
Compile, so its helpers serve the file-based tests too. MethodTests uses
it for the PDB side of the entry point:

- The Portable PDB header's entry point is the MethodDef of the method
  that carries .entrypoint, not the first MethodDef, and is nil when no
  method carries it.
…d narrow the test reader's blob check

BuildPdbMetadata now quotes docs/design/specs/PortablePdb-Metadata.md where it
writes a method's MethodDebugInformation row: the Document column names a
document only when every sequence point of the method is in it, and otherwise
it is nil and the sequence points blob carries the InitialDocument and the
document-records. The comment on the branch for methods without an IL body is
removed; the method's remarks already state that rule.

In the tests, PortablePdbTestReader.ReadBlobRecords, which returned every
record of a blob, becomes AssertNoDocumentRecordInSequencePointsBlob, the one
check its only caller needed. HiddenPoint_HasNoDocumentRecordAndBelongsToTheCurrentDocument
used to compare the whole record list with point@0, hidden@1, point@2; it now
asserts that the blob has no document-record, and its assertions on the
points' hidden flags and documents are unchanged. A new test checks that the
assertion fails for a method whose blob does have a document-record.
…e CodeView entry

The PDB names each input file by its full path, and without --deterministic
the image's CodeView entry records the full path of the PDB, so the output
depends on where the tree is. --pathmap (-PATHMAP=...) maps path prefixes in
both, so that with --deterministic, which already records only the PDB's file
name, the same tree assembled in two places gives byte-identical image and
PDB files.

The rules are the C# compiler's -pathmap. The value is a list of
<path>=<sourcePath> entries separated by ',', where ',,' and '==' stand for
the character itself; an entry without exactly one '=', or with an empty side,
is an error that names it, and an empty entry is skipped. Each path and
replacement that does not end with '/' or '\' gets the separator it already
uses (the platform's when it uses neither or both), so an entry matches whole
leading directories only, and the first entry that matches a path wins, with
the result's separators normalised to the replacement's when it uses only one
kind. The option can be given more than once and the entries apply in order.

Options.PathMap holds the map. PdbDocumentTable maps every document name
before it looks it up, so two names that map to one name are one document; this
covers the input files and the .line and #line file names. GetPdbFilePath maps
Options.PdbFilePath for the CodeView entry. Files are still read and written at
their real paths, and diagnostics still name them by those paths. An invalid
--pathmap fails the run before any file is read or written.

Tests: PathMapTests for the parsing and mapping rules; PdbDocumentTests and
CompilerOptionsTests for the documents and the CodeView path; and
CommandLinePathMapTests, which run the command line in process: the same source
in two directories, each mapped to /_/, gives identical files, both option
forms are accepted and accumulate, a malformed value fails and writes nothing,
and the PDB is written at its real path. CommandLineTests' native
value-option data gains PAT. No existing expectation changed.
The three-letter options that NativeCommandLine recognises exist only for
compatibility with the command lines of native ilasm, which has no path-map
option, so --pathmap gets no -PATHMAP form. The table entry is removed, with
the command-line tests' data rows for it and the test of the native form; the
remaining command-line tests pass the option as --pathmap <value>. A row in
UnrecognizedNativeOption_Throws checks that -PATHMAP= is rejected like any
other unknown native option.
…ical block

The managed ilasm appended every .locals declaration as the next slot of the
local signature and dropped the [n] of `.locals ([n] type name)`. It also
added the names declared in a nested { } block to the enclosing scope's names
when that scope had a .locals of its own, so an inner declaration of an outer
name resolved to the outer slot, and inner names stayed visible after the
block. Both change the IL of the body.

Locals now go through a per-method slot table, as in native ilasm's
EmitLocals: a declaration without [n] takes the slot after the highest one;
[n] takes slot n, padding the table up to it; the local signature has one
entry per slot in slot order. Redeclaring a slot that is in use reports
"Local var slot n is in use" (warning); as in native ilasm, a slot is in use
from a declaration until any scope that declared it closes. Redeclaring a
slot with another type reports "Local var slot n: type conflict", and the
slot takes the new type; a slot that no declaration types reports "Undefined
type of local var slot n in method m" and is written as int32. [-1] means no
explicit slot, as in native; an explicit slot outside the 16-bit range is
reported as written and the local takes the next slot, and a local whose next
slot would be past 65535 is reported and not declared. The range is checked
on the literal as written, so a literal too large for 32 bits is reported
too, rather than narrowed to a slot in range or to -1. A ... sentinel
standing alone in a .locals list no longer takes a slot, as in native ilasm.
On parameters, [n] keeps its meaning as a raw attribute value.

Each { } block, including .try, catch, filter, finally and fault bodies, now
opens a name scope that closes with it. A name resolves to the first
declaration of that name in the innermost open scope that declares it; names
are case-sensitive.

Tests: unit tests for each rule, and seeded generated methods with nested
blocks, explicit, padded and reused slots and shadowed names, checked against
a model of native ilasm's rules.
The managed ilasm wrote no LocalScope or LocalVariable rows, so debuggers saw
no local names. Each lexical scope of a method body (the body itself and each
{ } block, including .try, catch, filter, finally and fault bodies) is now
recorded when it closes, with its IL offsets and the named locals it declares,
and the PDB gets the rows native ilasm writes: one LocalScope per scope that
declares a named local, spanning the offsets at its { and } (the whole body
for the method's own scope), followed by one LocalVariable per named local
with the local's slot as Index and attributes 0. Unnamed locals get no row.
The scopes are recorded whenever a PDB is requested, by any of the switches
that request one.

The rows are sorted by start offset, then longest first, as the table
requires; scopes with the same range keep source order. Two cases where
native ilasm writes rows the specification does not allow are left out: a
scope without instructions (zero length), and a second variable with the
same name or index in one scope. Within a scope, each name gets a row at the
slot of its first declaration, the declaration the name refers to, unless an
earlier row of the scope already describes that slot; later declarations of
a name, and unnamed locals, take no part. So a row never describes a local
that its name does not refer to. Import scopes and local constants are not
recorded, as in native ilasm.

Tests: LocalScopeTests and LocalTests, and seeded generated cases that compare the
local signature, named references and LocalScope/LocalVariable rows with a model
of native ilasm's rules.
… no .line directive is active

With a PDB switch (-DEBUG in any mode, or -PDB), native ilasm records a
sequence point when it emits an instruction: on the instruction's own line
of the .il source, columns 1 to 2, when no .line or #line directive is in
effect, and with the coordinates of the directive in effect otherwise. An
instruction gets a point only when its coordinates differ from those of the
last point recorded, or a directive has been applied since. The managed
ilasm recorded points only when a .line directive was applied, so a .il file
without .line directives had a PDB with no sequence points, a .line after a
method's last instruction gave a point at the end offset, and a class-level
.line gave the next method's first instruction no point.

Points are now recorded in StartInstruction, which every instruction passes
through before its bytes are written. A .line or #line directive only
updates the state of the source it appears in (the input file, or one
inclusion of an #include'd file): its coordinates and, when it names one,
its file. That state lasts to the end of the source, across methods, as in
native ilasm; an included file starts without a directive and does not
change the state of the file that includes it. The last point is compared
across methods, as native ilasm does, so a later method without a .line of
its own gets no point while an earlier directive's span is still current.
An instruction produced by a #define macro is on the line where the macro is
used. An included file becomes a document when one of its instructions gets
a point, and the CLI names included files by full path, as native ilasm
does. Five differences from native ilasm are listed in
MANAGED-ILASM-FIXES.md.

A reference operand with a syntax error emits nothing and records no point.
A point recorded at the offset of the previous one replaces it, so the
sequence points blob never holds two points at one offset. Without a PDB
switch no points are recorded.

Changed expectations:
- CompilerOptionsTests.PdbOption_ProducesPortablePdbWithoutLineDirectives
  asserted only the single input document and a non-empty
  MethodDebugInformation table; it now also asserts the method's document
  and its two points on lines 6 and 7, columns 1 to 2.
- The PdbCaseGenerator document model recorded points when directives were
  applied and did not model the lines of the .il source; it now records
  them at instructions, with implicit points, instructions sharing a line,
  one- and two-byte instructions, blank and comment lines, directives after
  the last instruction, and methods or runs of instructions in included
  files.
- PdbDocumentTests.MethodWithLocalsButNoLineDirective_HasNoBlob is unchanged
  except for a comment saying why the method has no points.
…embler.Tests

CommandLineSequencePointTests runs the ilasm command line in process with
-DEBUG, as CommandLinePdbFileTests does, and reads the PDB it writes, for
what only the command line establishes:

- An input without a .line directive has its points on the instructions' own
  lines of the file, columns 1 to 2, with two instructions on one line
  sharing a point, in a document named by the input's full path, when the
  command line gives a path relative to the current directory or a full path
  with a ".." segment.
- A .line directive after an implicit point makes the method span the
  input's document and the file it names, and the method's
  MethodDebugInformation row names no document.
- The instructions of an #include'd file are in a document named by the
  included file's full path, and the including file's later instructions are
  back in the input's document. The include path is relative to the current
  directory (read, not set).
@pcshrosbree
pcshrosbree force-pushed the ilasm-goodwill/implicit-sequence-points branch from 410f012 to 5f62a5e Compare October 9, 2026 21:10

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-ILTools-coreclr community-contribution Indicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant