Conversion Strategies — Technical Reference

📖 This is the exhaustive technical reference. For a shorter, example-driven overview of how each Go construct maps to C#, start with ConversionStrategies.md — every section there links back here for the full detail. Read this document when you need the why: the exact emitted form, the edge cases, the Phase-3 fixes, the behavioral-test guards, and the C#-vs-Go semantic reasoning behind a decision. It is the authoritative record; the summary is the front door.

Updated 2026-06-27 for the “go2cs2” generation of the converter. This is a living document; as more use cases are converted these strategies are refined. The current converter is written in Go (using the official go/ast + go/types toolchain, under src/go2cs/) and emits C# that leans on two things the visible code does not show in full: a hand-written runtime library, golib (src/core/golib/), and a set of Roslyn source generators (src/gen/go2cs-gen/) that synthesize the Go semantics which cannot be written directly in C#. Notes that previously referenced the retired ANTLR4/C# converter or the old gocore library have been updated to reflect this. See also: Architecture.md, Glossary.md, Roadmap.md, and CLAUDE.md.

The guiding goal: the generated C# should be both behaviorally and visually similar to the original Go, so that a Go developer can read the output and follow it. The runtime library and the generators exist to keep the visible converted code close to the Go original.

How this reference is organized. Each ## topic opens with the high-level rule (the same ground the summary covers) and is then followed by ### subsections documenting specific conversion decisions, edge cases, and fixes — most keyed to the behavioral test that guards them. When updating the converter, add the deep detail here and the reader-facing example to the summary (see ../CLAUDE.md, “Record the conversion decision”).

Topics

Package Conversion

Although a Go package more traditionally parallels a C# namespace, Go includes referenceable functions directly from within a package root, for example, the Println function in the fmt package is called like: fmt.Println("Hello, world"). For C#, only type declarations, e.g., class, struct, enum, etc., are allowed in a namespace; functions exist as part of a class or struct. Described from a C# perspective, all Go functions are static, i.e., the functions exist separately from an instance of a type. Go supports the notion of a receiver function which allows a function to be targeted to an instance of a type (paralleling the operation of a C# extension function), but this is still a static function.

As such, the conversion strategy for a Go package is to convert it into a static C# partial class, e.g.: public static partial class fmt_package. Using a partial class allows all functions within separate files to be available with a single import, e.g.: using fmt = go.fmt_package;. The receiver functions are emitted as extension methods on that partial class (decorated with [GoRecv], see Source Generators).

So that Go packages are more readily usable in C# applications, all converted code is in a root go namespace. Package paths are simply converted to namespaces, so a Go import like import "unicode/utf8" becomes a C# using like using utf8 = go.unicode.utf8_package;. Each package also emits a package_info.cs carrying a [GoPackage] assembly attribute plus the package-wide global using aliases (Go’s built-in types, exported type aliases, etc.).

A consequence of converting a Go method to a C# extension method is that C# only discovers an extension method when its containing static class’s namespace is in scope (via a using <namespace>; directive or the enclosing namespace) — a class alias such as using atomic = go.@internal.runtime.atomic_package; resolves the type (atomic.Uint32) but does not bring the class’s extension methods into scope. This matters when a file calls a method on a value whose type comes from a multi-segment-path package (one that lands in a sub-namespace, e.g. internal/runtime/atomicgo.@internal.runtime): Go never requires importing a value’s package merely to call a method on it, so such a file may emit no import — and hence no using @internal.runtime; — leaving the extension method invisible and the call mis-binding to a wrong (e.g. embedding-promoted) overload (CS1929). The converter therefore registers the namespace of every cross-package method’s defining package as a file-local using at the call site, independent of the file’s explicit imports. (Packages in the root go namespace — most top-level stdlib packages — need nothing extra, since same-namespace extension methods are always visible. This is a stdlib-structural concern that only surfaces under multi-segment package paths, so it is guarded by the Phase-3 runtime build rather than a single-package behavioral test.)

Go projects that contain a main function are converted into a standard C# executable project, i.e., <OutputType>Exe</OutputType>. The conversion process can reference and convert needed external projects as library projects, i.e., <OutputType>Library</OutputType>, per any encountered import statements. In this manner an executable with packages compiled as project-referenced assemblies can be created. To create a single executable, like the original Go counterpart, a self-contained executable can be produced.

An executable’s <AssemblyName> is the last element of its import path, mirroring go build, which names a binary after the module/directory’s final segment — so module example.com/colordemo produces colordemo.exe, not example.com.colordemo.exe (the full dotted project name). Only the Exe assembly name is shortened; the .csproj filename keeps the full dotted path (its identity in the solution and in ProjectReferences), and library assemblies keep the full dotted <AssemblyName> — their DLL and NuGet PackageId (go.$(AssemblyName)) must stay unique across the package graph (e.g. github.com.fatih.color).

A project name is the package’s FULL import path — a go-file-free container directory does not truncate it

The dotted project name above is the package’s import path, every segment of it, joined with . (getProjectName, importOperations.go). That is not a formatting preference: the name is the .csproj filename, the library <AssemblyName>, the NuGet PackageId, and — minus its last segment — the C# namespace. All four have to be unique across the package graph, and the import path is the only thing about a package that is unique by construction.

For the standard library the path is read straight off GOROOT/src. For everything else the converter has only a directory, so it recovers the import path the way the go command itself finds the main module: walk up to the nearest go.mod, then join that module’s declared path with the package’s path relative to it. The result is the import path by definition.

The walk used to stop early. Alongside go.mod and main.go it treated the first ancestor directory holding no .go files of its own as a boundary and named the project after the leaf segment alone. Go modules are made of such pure container directories — internal/, a proto grouping like xds/core/, the datatransfer/ above an apiv1/, a service tree’s endpoints/ parent — so the truncation was routine rather than exotic, and it cost two things:

A go-file-free ancestor is therefore a fallback, never a stop: the walk records the leaf-relative name it would have produced and keeps climbing. The fallback applies only when there is genuinely no module root anywhere above — a GOPATH-style tree, which is the one case it was ever needed for. Because both the declaring package and every importer derive the name from the same call, the two sides move together.

One consequence worth knowing when re-converting: recovering the full path renames every package that was previously truncated (743 of the 1,727 above), so a re-conversion into an existing output root leaves the old, wrongly-named .csproj files behind as orphans that a solution may still list. Convert into a fresh output directory, or clear the old one first.

Guarded by TestProjectNameSurvivesGoFileFreeContainerDir and TestProjectNameFallsBackWhenNoModuleRoot (the unit invariant, including the fallback arm) and TestRecurseGoFileFreeContainerDirsKeepDistinctProjectNames (the whole -recurse conversion: distinct .csproj files, a .slnx with no repeated project name, and matching qualification in the importer’s converted code). That last one also exercises internal as a namespace segment, which only the recovered path produces — go.example.com.app.@internal.web.api_package, keyword-escaped on both the declaration and reference sides.

The emitted project PATH is budgeted — the file name compresses, the identity never does

Recovering the full import path made every project name correct, and on a deep dependency tree it made them long. The import path is then spelled twice on disk — once by the directory, which mirrors it, and once by the file name — so the deepest package of the conversion above emitted a 242-character project path before the user’s output root even counted. Visual Studio refused to load it, and the same solution failed all over again (issue #35, second report).

Three separate limits are in play, and only one of them is negotiable:

Limit Value Liftable?
Visual Studio’s project loader 260 characters (MAX_PATH) No. Measured on a machine with LongPathsEnabled=1: a 259-character .csproj loads and builds, a 265-character one fails with “The project file could not be loaded. Could not find a part of the path” — naming a file that is demonstrably present and that dotnet build reads without complaint.
A single filename component 255 characters No, ever. No registry key, \\?\ prefix or manifest touches it (measured: a 265-character path writes fine on that same machine, a 256-character file name does not).
Total path, for long-path-aware tools 32,767 with the key, 260 without Yes — dotnet build and Visual Studio’s own MSBuild.exe both handled a 280-character artifact tree.

So enabling long paths is not an answer, and neither is flattening the tree: pkg/<import-path>/<dotted>.csproj and pkg/<dotted>/<dotted>.csproj measure identically, because a separator costs exactly what a dot costs. The name cannot simply be shortened to the leaf either — that is what collided in the first report, and .slnx has no display-name override to separate the file name from the project name (measured: a DisplayName attribute does not help; Visual Studio still reports “Project name ‘v3’ already exists in the ‘/pkg/’ solution folder”). Spelling the path twice is the floor unless one of the two spellings is compressed.

The file name is what compresses; identity never does. projectFileBaseName bounds the emitted project path — <tree-root>/<import-path>/<name>.csproj — at 200 characters, which leaves an output root of up to 59 characters inside MAX_PATH. A name that fits is returned verbatim, which is every package in the standard library (longest emitted path: 101) and every behavioral test (109), so the committed corpus is untouched. A name that does not becomes head~tail.hash8: a readable head carrying the module, a readable tail carrying the leaf package, and eight hex characters of SHA-256 over the full canonical name. The C# namespace, the <AssemblyName> and therefore the NuGet PackageId all keep the full import path — those are identity, and they stay globally unique and legible in a stack trace. Replaying the reporter’s real 1,724-package conversion: 1,724 distinct names, 11 compressed, longest emitted path exactly 200.

The compression is a pure function of the canonical name, so it is set-independent, and that is the point. The reference side derives a dependency’s file name from the import path alone, knowing nothing about the rest of the conversion. A shortest-unique-suffix scheme would have been shorter still, but under it adding one dependency can rename unrelated projects — the same coupling the first report’s fix removed, one level up.

Derived paths are redirected rather than budgeted. obj\, bin\ and the generator output EmitCompilerGeneratedFiles writes multiply the import path instead of merely repeating it, so no name budget reaches them: on the reporter’s tree, 1,570 of 1,725 projects produced a generator path over MAX_PATH and 43 produced a file name over the unliftable 255. A -recurse conversion therefore emits a Directory.Build.props and Directory.Build.targets at the output root that move all three to <root>\.artifacts\{obj,bin,gen}\<token>\, where the token is 12 hex characters of [MSBuild]::StableStringHash over the project’s root-relative directory. Two details are load-bearing:

Hint names are capped to 128 characters in the analyzer itself (Common.GetValidFileName), compressing to <head>-<hash16>.g.cs. A hint name is only a label — Roslyn requires it to be unique within a generator and nothing else — so the cap has no semantic consequence and the files it names are git-ignored. The elision separator must come from the same allow-list IsValidHintNameChar enforces: a tilde there reaches AddSource unscrubbed and throws, which surfaces as CS8785, a warning, leaving the generator to contribute nothing and the build to fail on the type it should have emitted.

Guarded by TestProjectFileBaseNameLeavesCorpusNamesAlone, …BoundsTheEmittedPath, …BoundaryIsExact, …IsDeterministicAndSetIndependent, …KeepsDistinctNamesDistinct, and TestIssue35ReplayCorpus (which replays a real generated .slnx when GO2CS_ISSUE35_CORPUS points at one), plus TestRecurseDeepImportPathStaysInsideMaxPath — a whole -recurse conversion over the reporter’s actual shape, asserting the compressed file name, the untouched AssemblyName, the tree-wide path bound and the reference side’s agreement.

The recurse output root records its runtime root: the $(go2csPath) default

A local-references -recurse conversion pins the runtime root it resolved against into the output root’s generated Directory.Build.props (issue #36):

<PropertyGroup Condition="'$(go2csPath)' == ''">
  <go2csPath>C:/Users/mason/go2cs-runtime/</go2csPath>
</PropertyGroup>

The -go2cspath command-line flag is a conversion-time input — it is where the converter reads each imported package’s package_info.cs from and what it resolves $(go2csPath)core/… references against — but before this pin its value never reached the emitted build files. The MSBuild property of the same name then had only the csproj template’s fallback ($(USERPROFILE)/go2cs/, or $(SolutionDir) under Debug), so converting into an isolated output root produced a solution whose stdlib/runtime/analyzer references could not resolve unless the runtime happened to live at ~/go2cs or a deploy-core Directory.Build.props sat above the output. Three details:

Guarded by the go2csPath assertions in TestRecurseSyntheticModule (absolute pin, isolated output root), TestRecurseBuildFilesRelativePinWhenRootsCoincide (relative form), and TestRecurseNuGetReferences (no pin under NuGet references).

Path separators in emitted MSBuild files: forward slashes, on every host

Every path the converter writes into a .csproj, .slnx, .pubxml or Directory.Build.props uses /, on every host. There is no per-host emission and no host-conditional spelling: one converted corpus is correct on Windows, Linux and macOS.

<ProjectReference Include="$(go2csPath)core/fmt/fmt.csproj" />
<ProjectReference Include="$(go2csPath)gen/go2cs-gen/go2cs-gen.csproj" OutputItemType="Analyzer"  />
<OutDir>bin/$(Configuration)/$(TargetFramework)/</OutDir>
<GoValidationProofFile>$(go2csPath)../docs/validation/$(GoStdLibVersion).$(GoBuildNumber)/fmt.md</GoValidationProofFile>

MSBuild accepts / in every path context on Windows, and normalizes \ to / on Unix (FileUtilities.MaybeAdjustFilePath), so both spellings build on both hosts — the .NET SDK’s own targets depend on the Unix direction. The reason to pick one is that two other consumers are not MSBuild:

  1. The converter’s own path arithmetic. The reference used to be composed by hand — replace every / with \, then filepath.Join a backslash-prefixed file name. On Windows filepath.Clean folded that back into a well-formed path; on Unix filepath.Join treats \ as an ordinary filename character, so a Linux-hosted conversion emitted the malformed $(go2csPath)core\fmt/\fmt.csproj for every stdlib reference in every project — silent at emission, a restore failure later (F5, PLAN-linux-operation.md §A1.1). The composition is now emittedProjectReference (importOperations.go): path.Join (slash-only, host-independent) over a filepath.ToSlash‘d directory. writeProjectFile and writeTestProject additionally ToSlash at the emission point, because a sibling reference made relative by filepath.Rel arrives OS-native.
  2. The harnesses that READ an emitted reference. BehavioralRunner.PreBuildSharedDeps and PerformanceRunner parse ProjectReference Include="…" out of the csproj and resolve it with Path.GetFullPath, which on Linux does not split on \ either.

Consequence to expect when this changes: the sorted reference block can re-order. References are sort.Strings-sorted, and / (0x2F) sorts below alphanumerics while \ (0x5C) sorts above them, so a pair that differs at a separator boundary swaps. Across the whole 303-project stdlib the flip moved exactly one file’s ordering — net/http, where vendor/golang.org/x/net/http2/hpack had sorted before vendor/golang.org/x/net/http/httpguts (2 < \) and now sorts after it (/ < 2). Same set, different order; not a content change.

Seven hand-owned core files are never re-emitted (golib, testing, unsafe, internal/godebug, internal/concurrent, internal/weak, and core/Directory.Build.props), so they carry the form by hand. The one deliberate exception is the shared-project <Import Project="..\go2cs\go2cs.projitems" Label="Shared" /> in golib.csproj and go2cs-gen.csproj: that is Visual Studio’s own bookkeeping, VS round-trips its exact text, and MSBuild normalizes it on Unix regardless — so it stays backslashed, with a comment saying why.

Guarded by TestEmbeddedCsprojTemplatesUseForwardSlashesOnly and TestValidationPackBlockUsesForwardSlashesOnly (csprojTemplate_test.go — both templates are asserted to contain no backslash at all, so a future addition is covered without the guard enumerating it) and by TestEmittedProjectReferenceIsHostIndependent / TestEmittedProjectReferenceForModuleCachePath (importOperations_test.go).

A GOROOT-vendored reference is named for the package’s ON-DISK path

A standard-library reference takes its project file name from the directory it resolved to, not from the import path as written. The two are the same string for every package in the standard library except one class — the GOROOT-vendored ones, imported as golang.org/x/… but existing on disk, and therefore as converted projects, only under vendor/golang.org/x/….

<!-- crypto/ecdh imports `golang.org/x/crypto/chacha20` -->
<ProjectReference Include="$(go2csPath)core/vendor/golang.org/x/crypto/chacha20/vendor.golang.org.x.crypto.chacha20.csproj" />

The directory half was always right — it is rewritten from the resolved source dir — while the file name was composed from the import path, so the emitted reference named a real directory and a file in it that exists nowhere: …/vendor/golang.org/x/crypto/chacha20/golang.org.x.crypto.chacha20.csproj. Deriving the name from the directory (stdLibImportPathFromTargetDir, applied in both stdlib arms of importOperations.go) is what makes the two halves agree structurally rather than coincidentally: getProjectName — the producer, which names the .csproj the vendored package actually emits — has always derived it from that same GOROOT/src-relative directory.

This is the third derivation in one family, and they must all resolve the vendored spelling or they disagree about which package is being named: the namespace (resolveGorootVendoredPath, under Cross-package imports), the dependency-graph key (stdLibConverter), and now the project file name.

Two consequences beyond the file name, because PackageName is not only a file name:

Where it surfaced, and what it did NOT do. Only a -tests conversion could emit it: production emission resolves the vendored path upstream (visitImportSpec), while the test project’s dependency list is the raw import set, which carries both spellings — so crypto/ecdh’s test project named the package twice, once correctly and once not. It is worth being precise about the damage, because a missing <ProjectReference> sounds fatal and is not: MSBuild degrades it to warning MSB9008 and builds on (measured — the pre-fix crypto.ecdh.tests.csproj builds, 0 errors), and here the correct sibling reference supplied the assembly anyway. The real cost was downstream: the stale name was harvested into go2cs-stdlib.slnx as a phantom 308th project by the multi-platform merge’s solution-recovery path (fixed on the solution side by TestCollectConvertedProjectsIgnoresTestProjectReferences; this is the emission half).

Guarded by TestGorootVendoredReferenceNamesTheVendoredProject (the vendored spelling, the metadata key, and the leaf package name), TestStdLibImportPathFromTargetDir (the recovery, including the non-core-rooted no-match that leaves the caller on the import path) and TestStdLibReferenceUnchangedForUnvendoredPackage (the no-op half — the whole corpus bar the vendor/ tree, which is what makes a zero-movement CNR verdict meaningful rather than lucky), all in importOperations_test.go.

Generated output path: $(OutDir) defers to $(BaseOutputPath)

Both project templates (src/go2cs/csproj-template.xml and the -tests host’s test-csproj-template.xml) give the generated project a stable default output path:

<!-- Build outputs are copied to $(OutDir), so this default must also defer to an explicit
     $(BaseOutputPath); otherwise that redirection silently never takes effect. -->
<PropertyGroup Condition="'$(OutDir)'=='' AND '$(BaseOutputPath)'==''">
  <OutDir>bin/$(Configuration)/$(TargetFramework)/</OutDir>
</PropertyGroup>

The AND '$(BaseOutputPath)'=='' half is load-bearing and was added 2026-07-26. MSBuild copies build outputs to $(OutDir), and $(OutDir) is what the SDK derives from $(BaseOutputPath) late in the import order — so a template that pins OutDir in the project body outranks any BaseOutputPath an outer Directory.Build.props sets, and that redirection is discarded without a warning. The generated project would keep writing to bin\<Config>\<tfm>\ while OutputPath reported the redirected location, which is exactly how the defect hides.

Three separate isolation intents were silently defeated by the unconditional pin, all found at once:

Two guards pin this. TestCsprojTemplateEmitsWellFormedXml / TestTestCsprojTemplateEmitsWellFormedXml (src/go2cs/csprojTemplate_test.go) substitute each template exactly as its emitter does and stream the result through encoding/xml, which enforces the same comment rules MSBuild’s loader does — a malformed template breaks the entire corpus at compile time and is otherwise only visible ~450 s into a full behavioral run. On the measurement side, PerformanceRunner reads the JIT binary’s runtimeconfig.json before timing anything and fails the run if it is self-contained or has dynamic code disabled.

Per-GOOS sources: layout L3 and $(GoTargetOS)

Go selects its platform sources at build time — filename suffixes and //go:build constraints — so a conversion does not merely target a platform, it is that platform. A converted package whose emission varies across GOOS therefore keeps the varying files in per-GOOS subfolders, and the .csproj compiles exactly one of them; files that are byte-identical on every platform stay flat. This is layout L3 (phase4/DESIGN-multiplatform-corpus.md §8, accepted 2026-08-08). internal/goos is the first package to carry it:

src/core/internal/goos/goos.cs                    shared by windows, linux and darwin
src/core/internal/goos/package_info.cs            shared
src/core/internal/goos/windows/nonunix.cs         public const bool IsUnix = false;
src/core/internal/goos/windows/zgoos_windows.cs   public static readonly @string GOOS = @"windows"u8;
src/core/internal/goos/linux/unix.cs              public const bool IsUnix = true;
src/core/internal/goos/linux/zgoos_linux.cs       public static readonly @string GOOS = @"linux"u8;
src/core/internal/goos/darwin/…

Such a package’s .csproj gains exactly two blocks — the selector, and the include that reads it:

<PropertyGroup Condition="'$(GoTargetOS)'==''">
  <GoTargetOS>windows</GoTargetOS>
</PropertyGroup><Compile Remove="**/*.cs" />
  <Compile Include="*.cs" />
  <Compile Include="$(GoTargetOS)/*.cs" />

The include must follow <Compile Remove="**/*.cs" />, which would otherwise remove it — MSBuild evaluates items in document order. The property may sit anywhere (properties are evaluated in an earlier pass) but is declared above the item that reads it. windows is the default because the corpus that exists today is the Windows emission, so a plain dotnet build reproduces the single-platform package this layout replaced: verified byte-identical, and -p:GoTargetOS=windows produces the same assembly as the property absent.

Only a package that varies gets the blocks. The design measures the varying set at 37 of 304; the rest emit the same C# on every platform and keep exactly the project file they always had.

Which files are per-GOOS cannot be decided by one conversion. The platform axis is a comparison of several targets’ emissions, and it is emphatically not derivable from Go file sets: four packages emit different C# from identical Go source (constant folding, escape analysis, cross-file collision renaming, dead-branch folding), and three emit identical C# from differing Go source. So the layout is produced by a multi-target run — go2cs -stdlib -comments -platforms windows/amd64,linux/amd64,darwin/amd64 — which converts once per target into a seeded staging root, classifies every emitted artifact (shared / variant / partial / exclusive), and merges the result into one tree.

A single-target conversion instead honors a layout the output tree already carries: if the package directory holds <goos>/<name>.cs, that is where <name>.cs is written, and a package directory holding any per-GOOS source folder gets the two blocks. That is what makes an ordinary seeded reconvert reproduce an L3 package file for file, instead of laying a flat duplicate beside the copy the project is already compiling — a duplicate-member break that would otherwise arrive silently. A per-GOOS folder is distinguished from a nested package by the project file every converted package directory holds and a source folder never does: internal/syscall/windows is a real package whose own directory name is a GOOS.

Guarded by platformLayout_test.go (src/go2cs), that negative case included.

A HAND-OWNED file has a platform too, and the classifier cannot see it. L3 decides placement by comparing emissions — and a hand-owned file is never emitted, so it is never classified, so it silently keeps whatever placement it had while the file it belongs to moves per-GOOS. That is invisible on Windows, where the compile item set is identical either way, and it took the entire Linux corpus down at increment 3: runtime/lock_sema_impl.cs supplements lock_sema.cs, which Go selects on Windows and macOS but never on Linux, so the Linux build compiled the flat companion against a principal that was not in its build.

The rule is stated as a platform set, not as a folder: a hand-owned file belongs in exactly the platform builds its principal takes part in, after which L3’s ordinary placement rule applies unchanged — every platform ⟹ flat, a subset ⟹ one copy per platform in the subset. Read literally as “inherit the principal’s folder”, os/proc_impl.cs and syscall/syscall_impl.cs would be triplicated: their principals are per-GOOS variants present on all three platforms, so three copies of one hand-written file would have to be maintained in lockstep for no compile benefit. L3 duplicates only what cannot be shared.

A principal comes in two shapes, and both are already recorded in the tree by the emission itself:

Hand-own Principal Why
<name>_impl.cs <pkg>/<name>.cs a companion with no Go counterpart of its own; it supplements the converted file
<name>.cs carrying [module: GoManualConversion] <pkg>/<name>.cs.auto the conversion still runs and only its EMISSION diverts, to the review sibling — which is therefore emitted by exactly the platforms that compile the Go file this hand-own replaces

The second binding is the one that catches a whole-file hand-own of a platform-exclusive Go file: syscall/dll_windows.cs and syscall/exec_windows.cs replace Windows-only sources and now live in syscall/windows/. The .cs.auto sibling moves with its .cs so the pair cannot separate — which is also what a single-target reconvert already does, since conversionDriver routes both through platformLayoutPath. A hand-own whose principal no target emitted (runtime/managed_impl.cs, internal/poll/runtime_sema_impl.cs — go2cs machinery with no Go file behind it) has no placement evidence and is left alone. Two existing copies of one hand-own that disagree are an error, never a first-wins choice: a duplicated hand-own is hand-maintained in each folder, so propagating one over the other is how a fix applied to a single flavor would disappear.

A hand-owned FUNCTION is a different problem with no layout answer. manualConversionFuncs is keyed by NAME and is platform-blind, so an entry turns its Go declaration into a placeholder on every platform while the implementation exists only where one was written — and notetsleep_internal is even four arguments in lock_sema.go against two in lock_futex.go. Census and remedy in phase4/DESIGN-multiplatform-corpus.md §7.

Guarded by platformHandOwn_test.go, which walks the real src/core rather than a synthetic tree — the next offender will be a file somebody adds by hand. Three structural rules: an *_impl.cs whose principal is in some but not all of its package’s per-GOOS folders must be in exactly those; a .cs.auto lives beside the .cs it reviews; and a source carrying Go’s own GOOS filename constraint (*_windows.cs, *_linux.cs, *_darwin.cs, with an _impl suffix stripped first) is never flat in an L3 package. That third rule exists because a static walk cannot find a marked hand-own’s principal — it is what would have caught dll_windows.cs/exec_windows.cs a whole increment earlier.

References are conditioned by the same rule, one level up. A package’s direct imports can differ by GOOS too — measured at 21 packages, os being the clearest: it imports internal/syscall/windows on Windows and internal/syscall/unix on Linux and macOS. One flat reference list cannot say that, so the references common to every platform stay unconditioned and only the differences are selected:

  <ItemGroup>
    <ProjectReference Include="$(go2csPath)core/golib/golib.csproj" />
    …the 16 references os has on every platform…
  </ItemGroup>

  <ItemGroup Condition="'$(GoTargetOS)'=='darwin'">
    <ProjectReference Include="$(go2csPath)core/internal/syscall/unix/internal.syscall.unix.csproj" />
  </ItemGroup>

  <ItemGroup Condition="'$(GoTargetOS)'=='windows'">
    <ProjectReference Include="$(go2csPath)core/internal/godebug/internal.godebug.csproj" />
    <ProjectReference Include="$(go2csPath)core/internal/syscall/windows/internal.syscall.windows.csproj" />
  </ItemGroup>

A platform whose delta is empty still gets a group, written self-closing (<ItemGroup Condition="'$(GoTargetOS)'=='linux'" />). That is not noise, and it is the one detail the whole mechanism rests on: the shared list is an intersection, and a single-target reconvert recovers the other platforms’ sets from the file it is about to overwrite. Forget that linux takes part and the next reconvert computes the intersection over two platforms instead of three — promoting a Windows-only reference into the shared list, where it would land in the Linux build. The empty group records membership so the axis survives.

Both producers — the multi-target merge and a single-target reconvert — go through one renderer, which is what makes the reconvert reproduce the merge’s bytes rather than something merely equivalent. When every platform’s imports agree again the block disappears and the project file returns to its plain form.

Two facts that are NOT references still have to be reconciled, because one .csproj serves every platform: <AllowUnsafeBlocks> is emitted as the union across targets (it differs in os/user and syscall; the property grants a capability rather than using one, so raising it is inert where unused), and every other companion artifact — README, icons, .cs.auto — is taken from the first target in -platforms order, the reference flavor, so the choice is deterministic instead of depending on which target happened to have something to rewrite.

Guarded by platformProject_test.go, whose central test is the invariant itself: a single-target reconvert of every platform must reproduce the merged project file byte for byte.

Build-warning suppression: what the emitted .csproj silences, and what it deliberately does not

Both templates carry one suppression policy, and it is a policy rather than an accretion: every entry is a diagnostic that is structural to the Go emission model, and every diagnostic that is not is left visible on purpose. The full census, code by code, is docs/phase4/DESIGN-warning-suppression.md.

<Nullable>annotations</Nullable>
<NoWarn>CS0162;CS0164;CS0282;CS0660;CS0661;CS1717;CS1718;CS8618;CS8860;CS8974;CS8981;IDE0060;IDE1006;CA2255</NoWarn>

The NoWarn entries each name an emission the converter re-creates on the very next conversion: Go type names are lower-cased ASCII (CS8981, and CS8860 for a Go type literally named record); a struct’s fields are split between its type file and package_info.cs (CS0282); Go comparison operators are emitted on value types without Equals/GetHashCode (CS0660/CS0661); Go zero values leave non-nullable fields uninitialized (CS8618); if (raceenabled) bodies and the break; appended after a case that already throw panic(…)s are unreachable (CS0162), as are the synthetic continue_<label>:/break_<label>: pairs emitted for every labeled Go statement (CS0164); the named-return store on the goto ᒐdone path through a defer frame is a self-assignment (CS1717); return f != f; is Go’s NaN idiom verbatim (CS1718); a function value in a map[string]any looks like a forgotten call (CS8974); and Go init() is modeled as a [ModuleInitializer], which the library-hygiene analyzer objects to by design (CA2255). IDE0060/IDE1006 never fire at the command line — they exist for Visual Studio’s live analysis, where every _-shaped parameter and every Go identifier would otherwise be flagged.

Nullable is annotations, not enable. Go has no non-nullable pointer, interface, map, slice, channel or func — every one of them is nil-able by construction — so C#’s nullable flow analysis is asking a question the source language cannot pose, and the only way to satisfy it would be to annotate the whole emitted corpus ?, burying the Go shape the project exists to preserve. Nor is it protecting a semantic go2cs wants: a converted program that dereferences a nil Go value should fault, because that is Go’s nil-pointer panic. annotations keeps ? meaningful (golib’s ж<T>?, PanicException?) and keeps default! legal while turning the analysis off; disable would be wrong, because it makes every ? in the emitted code a fresh CS8632. One consequence is load-bearing: CS8618 stays in NoWarn even under annotations, because go2cs-gen emits #nullable enable at the top of each generated .g.cs and a file-level directive beats the project property.

The publish properties are scoped off Library. PublishReadyToRun/PublishTrimmed/ IncludeNativeLibrariesForSelfExtract/EnableCompressionInSingleFile sit under Condition="'$(OutputType)'!='Library'", because the SDK turns PublishTrimmed into EnableTrimAnalyzer=true at build time — so on a library, which is never published, that one line was the sole source of every IL#### warning in the corpus while buying nothing. A converted main package still gets the analysis, and the trimmer re-runs over the whole closure at app publish, where it is actionable. AllowUnsafeBlocks shares that group’s history but not its condition: it is a compile setting, and the converted stdlib is full of library packages that do not compile without it.

Codes deliberately left visible are the other half of the policy — CS0219 (dead named-return locals, the one static signal for a genuinely dropped assignment to a named result), CS8778 (a live 32-bit truncation in nint-typed int64 constants), CS0675, CS8500 (the managed-referent hazard the S1 fork ruling is about — golib suppresses it locally, the corpus must not inherit it), CS8826, CS0252, CS0649, CS1522. Each is a converter or golib defect wearing a warning’s clothes; suppressing them would delete the signal rather than fix the emission.

Six .csproj are hand-owned and carry the policy by hand rather than by emission — core/unsafe and core/testing (skip-listed packages), core/internal/godebug, core/internal/weak and core/internal/concurrent (whose only Go file is fully hand-owned, so unmarkedFileCount == 0 makes the driver continue before writeProjectFile), and core/golib. golib keeps a shorter, deliberately different list: it is hand-written, it is the reflection/unsafe core, and its trim and nullable warnings are a real to-do list with an owner rather than emission noise.

Three guards pin all of this in src/go2cs/csprojTemplate_test.go: TestBothCsprojTemplatesCarryTheSameSuppressionPolicy asserts the two templates agree on Nullable and on the NoWarn set exactly (so adding a code forces the design-doc update, and dropping one fails), and TestPublishPropertiesAreScopedOffLibrariesButAllowUnsafeBlocksIsNot parses the rendered project and pins both halves of the publish-group split — a regression that moved AllowUnsafeBlocks under the condition would break the corpus in a way no warning count would reveal.

Cross-package imports (importing another package / assembly)

When a package imports another and uses its exported surface, the converter must agree, on both the producer side (converting the imported package) and the consumer side (resolving the import), on the imported package’s C# (namespace, class) and emit a ProjectReference to its generated .csproj. The package class is <packageName>_package and the namespace is the root go plus the import path’s leading segments, so the two sides line up when the Go package name equals the import path’s last segment (the usual layout: import "x/y/barlib" → package barlibgo.x.y.barlib_package). The consumer emits using barlib = …barlib_package; and references members as barlib.Thing.

Resolving where an imported package lives is module-aware: the standard library is found under GOROOT and mapped to $(go2csPath)core/<pkg>, but a local/user module (reached via a go.mod replace, or simply co-located in the same module tree) is invisible to the legacy go/build GOPATH resolver. The converter therefore falls back to the dependency directory captured from the module-aware go/packages load, treats that package’s converted output as in-place (co-located with its Go source), and emits a ProjectReference relative to the referencing project (e.g. ../barlib/barlib.csproj) so the generated .csproj is portable. This is what makes “import a sibling package and use it” compile as separate assemblies.

Exported type aliases cross packages. A package-level Go type alias that is exported — type Temperature = Celsius — is recorded in that package’s package_info.cs as an assembly attribute in its <ExportedTypeAliases> block:

// <ExportedTypeAliases>
[assembly: GoTypeAlias("Temperature", "go.CrossPkgLib_package.Celsius")]
// </ExportedTypeAliases>

A consumer that imports the package and names CrossPkgLib.Temperature cannot use a C# member-access for it (C# has no namespace-level type alias). Instead, the converter parses the imported package’s package_info.cs, reads its [GoTypeAlias] attributes, and emits a corresponding global using into the consumer’s own <ImportedTypeAliases> block — keyed by a package-qualified name whose . separator is the extended-Unicode dot (, a valid C# identifier character), since CrossPkgLib.Temperature is not a legal C# identifier:

// <ImportedTypeAliases>
global using CrossPkgLibTemperature = go.CrossPkgLib_package.Celsius;
// </ImportedTypeAliases>

The consumer’s converted code then refers to the alias as CrossPkgLibꓸTemperature. (This round-trip depends on the module-aware resolution above to locate the imported package’s package_info.cs; a stdlib dependency is found under the core output tree, a local module via its go/packages directory.) Guarded by the CrossPkgLib/CrossPkgUser cross-package behavioral test pair.

A collision-renamed alias chain resolves to its concrete target. When an exported type’s name collides with a method name it is Δ-renamed (see Type-vs-Method Name Collisions); and when that type is also an empty interface — type Token any colliding with a Token() method, encoding/json’s shape — the producer’s package_info.cs carries a two-hop chain. The collision analysis records Token → ΔToken, and visitTypeSpec (which renders an empty-interface target as object) records the renamed declaration ΔToken → object:

[assembly: GoTypeAlias("Token", "ΔToken")]
[assembly: GoTypeAlias("ΔToken", "object")]

A consumer that resolves only the FIRST hop and then qualifies the intermediate Δ-name as a package member emits global using jsonꓸToken = go.encoding.json_package.ΔToken; — but ΔToken is an assembly-scoped global using, not a namespace member of json_package, so it is CS0426 (encoding/json’s Token consumed by html/template, internal/coverage/cfile, expvar, log/slog, internal/fuzz, …). The imported-alias loader (loadImportedTypeAliases) therefore follows the chain within the producer’s OWN exported aliases to its concrete target, emitting global using jsonꓸToken = object;. A chain whose final target is a real Δ-renamed member (a delegate/struct such as ΔFilter, which is not itself an exported alias) stops there and stays package-qualified, unchanged. Guarded by the CrossPkgLib/CrossPkgUser pair — an empty-interface Token colliding with a Sensor.Token() method, named as a var type in the consumer and its boxed value read back, output-compared vs Go.

Imported stdlib alias metadata loads from the tree the assembly is COMPILED from — which since 2026-08-01 is the only tree there is. (Resolved; kept because the failure mode is instructive.) The alias round-trip locates the imported package’s package_info.cs under the core output tree. While the converted standard library lived in a SECOND tree, a -tests build compiled its stdlib dependencies from go-src-converted while loadImportedTypeAliases still read package_info.cs from the baseline core stub — and most stubs had no package_info.cs at all (runtime was impl-stubs only), so the alias map came back empty and a test’s cross-package reference to a collision-renamed stdlib type rendered the RAW, undefined qualified name: err.(runtime.Error)runtime.Error (CS0426) instead of runtimeꓸErrorruntime_package.ΔError (math/bits’ Div overflow/divide-zero panic asserts). The fix at the time derived the alias-load directory from the project-reference remap itself, so the two authorities could not drift. Both the remap and that derivation are now deleted: the converted stdlib lives at $(go2csPath)core/<pkg>, exactly where every resolver already pointed, so the alias-load path and the compile path are the same path by construction. The lesson outlives the machinery — metadata must be read from the tree the code is compiled from, never from a parallel one that merely looks like it.

A same-named cross-package alias target is fully qualified. Two different packages can share a Go package name — html/template and text/template are both package template. When such a package aliases the other’s type — html/template’s type FuncMap = template.FuncMap, whose target lives in text/template — the alias RHS must name the target’s OWN (namespace, class): go.text.template_package.FuncMap. getFullyQualifiedTypeName had gated its cross-package branch on the package name (pkg.Name() != packageName), so a same-named foreign type read as same-package and fell through to the t.String() path, whose cross-package slash-strip drops BOTH the text path segment AND the _package class — emitting global using FuncMap = go.template.FuncMap; (CS0234; template is not a namespace of go). The check now compares package identity (pkg != v.pkg), matching getAliasQualifiedTypeName and collectCrossPackagePaths, so the branch fires and the target fully qualifies. (A code-body reference already rendered correctly — getAliasQualifiedTypeName keyed on identity — so only the global using alias RHS was wrong.) Guarded by the CrossPkgSameNameAlias behavioral test: a package atomic that aliases the same-named sync/atomic’s Int32 (type Int32 = atomic.Int32), whose global using RHS must render go.sync.atomic_package.Int32, not the dropped-segment go.atomic.Int32.

A //go:linkname VARIABLE pull becomes a forwarding property to the (publicized) remote. Go’s //go:linkname local pkgpath.remote on a bodyless package var aliases local to another package’s remote — SAME storage, resolved by the linker. math/bits’ //go:linkname overflowError runtime.overflowError (its Div panics with overflowError, a runtime.Error) emitted a null field, so the converted Div panicked with null. Go 1.23 requires the definition side to authorize the pull with a one-argument handle (runtime/linkname.go’s bare //go:linkname overflowError); the authorization is puller-AGNOSTIC, so the faithful C# emission of a handle-marked var is public (a puller in a separate assembly must reach it) — a purely local decision each package makes from its own directives, no cross-package coordination. The pulling var then emits as a forwarding property to the fully-qualified remote (resolves in namespace go; without a using), and the remote’s package is queued for a project reference:

// runtime (definition side, one-arg handle):
public static error overflowError = ((error)((errorString)(@string)"integer overflow"u8));
// math/bits (two-arg pull):
internal static error overflowError { get => go.runtime_package.overflowError; set => go.runtime_package.overflowError = value; }

Three safety gates keep the emission compilable, each narrowing forwarding to what C# can express (the rest keep the pre-feature null-field/heap-box form): (1) a handle var is publicized only when its type is itself publicly accessible — runtime’s sched (schedt), writeBarrier (anon struct), lastmoduledatap (*moduledata) have unexported types and stay internal, since a public member cannot expose a less-accessible type (CS0052/CS0053) and such a var could not be pulled cross-assembly anyway; (2) a pull whose forwarding reference would form a project-reference cycleruntime pulling internal/syscall/windows.CanUseLongPaths, where the target transitively depends on runtime — keeps its null field (Go’s link-time linkname has no package cycle; a C# project reference cannot be circular; detected via the -stdlib dependency graph’s DependsOn); (3) an address-taken pull — reflect’s //go:linkname zeroVal runtime.zeroVal with &zeroVal[0] — keeps its addressed-global heap box, because a property has no address (ᏑzeroVal would be CS0103). The definition-side handle collection (collectLinknameHandles, a package-wide pre-pass like collectPublicizedTypes) and the pull recognition live in linknameOperations.go. (Guarded by the LinknameVarPull/LinknameVarPullLib behavioral test pair — a consumer package pulling an unexported handle var from a separate provider assembly, its value printed and output-compared vs go run; the full corpus compiles with the runtime handle vars publicized and the acyclic pulls forwarded. This is what unblocks math/bits’ Div panic tests from null.)

Exported structs and interfaces cross packages. An exported struct’s fields and methods are reachable on the consumer side exactly as the producer emits them — CrossPkgLib.Sensor{Name: …, Temp: …} lowers to a C# constructor call and s.Name / s.Hot() to field/method access on the imported struct — because the struct and its [GoRecv] extension methods live in the (referenced) library assembly.

A cross-package interface satisfaction is subtler. Go is structurally typed, so a consumer may assign any value with the right method set to an interface; C# requires the nominal partial struct T : I implementation glue, which the ImplementGenerator can only add to T in T’s own assembly (isLocalImplType). The converter records a [assembly: GoImplement<T, I>] for each concrete→interface conversion it witnesses while converting T’s package — so for a consumer to use Sensor as Labeled across the assembly boundary, the satisfaction must be witnessed in the library that declares Sensor. The idiomatic Go interface-satisfaction assertion does exactly this:

var _ Labeled = Sensor{}   // in CrossPkgLib — records GoImplement<Sensor, Labeled> in this assembly

With that, the library emits [assembly: GoImplement<Sensor, Labeled>], Sensor : Labeled is realized in the library assembly, and a consumer’s var l CrossPkgLib.Labeled = s / CrossPkgLib.Describe(s) compile as ordinary upcasts. (A library that returns the interface from a constructor — func New(...) Labeled { return Sensor{…} } — witnesses it the same way.) A type that satisfies an interface but is never used as it within its own package gets no nominal glue — proactively recording every local concrete→local interface structural match WAS tried (a declaration-site scan, 2026-07) and was RETIRED on 2026-07-25, because it could never be complete (it cannot see a dynamic type in a later-converted assembly) and paid for its incompleteness in speculative glue. Such a satisfaction is resolved at run time by the interface’s duck-typing shells instead; a declared conversion still takes the nominal fast path. Also guarded by the CrossPkgLib/CrossPkgUser pair (Phase 3: struct field access + interface satisfaction).

A sub-package import whose leading segment is a package alias root-qualifies

When a package imports both a parent package and its sub-package — testing/fstest importing io and io/fs — the converter emits using io = io_package; (a type alias for the io package class) and, for io/fs, a relative namespace target io.fs_package. In C# the leading io segment of io.fs_package binds to that type alias, so io.fs_package[.FS] resolves to the nonexistent nested type io_package.fs_package[.FS] — CS0426 (the using fs = … alias line, the embedded fs.FS getter, and the generated TypeGenerator copies). The converter records every direct-import using-alias identifier bound in the package and prefixes go. onto any multi-segment relative namespace/type whose leading segment is one of them, so the segment resolves as the child namespace it names:

import (
    "io"
    "io/fs"
)
type fsOnly struct{ fs.FS }
using fs = go.io.fs_package;
public go.io.fs_package.FS FS;

The unqualified io.fs_package.FS is retained as the promotedInterfaceImplementations map key (which feeds alias-less generator files where the relative form resolves). This complements the existing alias-vs-child-namespace Δ-rename, which only catches <currentNS>.<alias> collisions. (Recurs for any parent+sub-package import pair; a behavioral guard is owed — the io/fs embedded-interface pattern needs a parent+sub-package pair absent from the core baseline stdlib.)

The same collision detection must see GOROOT-VENDORED namespaces. visitImportSpec resolves a GOROOT package’s golang.org/x/… import to its on-disk vendor/… path (and namespace) when the importing file lives under GOROOT, but computeImportAliasRenames built packageChildNamespaces from the raw imp.Path() — so a vendored sub-namespace like go.vendor.golang.org.x.text.unicode was absent from the map. rootQualifyIfAmbiguous then could not see that a stdlib alias’s leading segment collides with it: bidirule (at vendor/golang.org/x/text/secure/bidirule, importing both unicode/utf8 and the vendored golang.org/x/text/unicode/bidi) emitted using utf8 = unicode.utf8_package;, whose unicode bound to the in-scope vendored unicode namespace rather than stdlib go.unicode (CS0234). computeImportAliasRenames now applies resolveGorootVendoredPath to each closure path when the package lives under GOROOT — matching the emission — so the vendored namespaces populate the map and the alias root-qualifies to go.unicode.utf8_package. Gated on GOROOT so a user module’s own golang.org/x dependency is untouched; guard owed (the fix fires only for a GOROOT-vendored package, which the behavioral harness — never under GOROOT — cannot express; validated by the bidirule reconvert A/B).

A DOTTED build tag (goexperiment.X, amd64.vN) is matched against the host toolchain’s tool tags. The converter re-checks each file’s //go:build constraint after go/packages has already loaded it (to drop files for the wrong GOOS/GOARCH when converting cross-platform). Its evaluator only handled bare identifiers (linux, amd64), so a dotted tag parsed as a selector and fell through to false. That is wrong for an experiment enabled BY DEFAULT: coverageredesign, regabiwrappers, and regabiargs are in the host’s go/build ToolTags, so go/packages loaded their //go:build goexperiment.X _on.go files — but the re-check then re-EXCLUDED them (the selector → false), dropping the package-level consts (testing’s goexperiment.CoverageRedesign, CS0117 ×4). The evaluator now resolves a dotted tag by membership in build.Default.ToolTags — so an enabled experiment’s _on.go survives and a disabled one’s !goexperiment.X _off.go survives, exactly the one go/packages chose. Blast radius is only internal/goexperiment (the sole stdlib package whose file selection flipped). Guard owed — the fix depends on the host toolchain’s active tool tags, which the go2cs/* behavioral harness cannot express portably; validated by the reconvert A/B (only internal/goexperiment changes) and the census (the consts appear, testing’s CS0117 clear). (That tag lookup now lives in the single matchTag callback described next; it was a *ast.SelectorExpr case while the converter still parsed constraints itself.)

Build constraints are parsed and evaluated by go/build/constraint

The re-check above needs a constraint parser, and for a long time the converter hand-rolled one: it lowercased the expression text and handed it to parser.ParseExpr, then walked the resulting ast.Expr itself. That parser is for Go expressions, and a build constraint is not one. A Go release tag is the case that breaks it — go1.21 reads as the identifier go1 followed by an illegal .21 selector, so every constraint mentioning a release failed with failed to parse build constraint: 1:4: expected 'EOF', found .21. conversionDriver warns on that error and falls through to including the file, so the failure hid behind an accidentally-correct outcome for as long as the only constraints that hit it were bare go1.N gates (five go-logr files, surfaced by a -recurse probe of go.opentelemetry.io/otel). It stops being correct the moment a constraint mixes a release tag with a platform: //go:build go1.21 && windows converted for linux lost its platform half along with the rest of the expression.

Constraints are now parsed and evaluated by go/build/constraint, the package the toolchain itself uses, which retires the whole custom parse/eval layer rather than special-casing the dot:

Release tags are asked of the go command, not of the compiled-in list. This is the one place where “use build.Default” — right for ToolTags — is wrong. Under GOTOOLCHAIN=auto the go command re-execs a newer toolchain when the main module asks for one, so go/packages can be selecting files under Go 1.25 while the build.Default.ReleaseTags linked into a go2cs built with Go 1.23 stops at go1.23. The re-check would then call go1.24 false where the loader called it true and drop the file — and the !go1.24 sibling was already dropped upstream by the loader, leaving the package with neither half of a fallback pair. Over-exclusion is this pass’s recurring failure mode (the purego seeding and the goexperiment branch above each exist to undo one) and it is the dangerous direction, because the loader has already applied the full constraint for the target platform — anything this pass subtracts is real code. So release tags come from go env GOVERSION run in the same directory packages.Load uses, expanded to go1.1go1.N. An unreachable or unparseable toolchain falls back to the compiled-in list — never to an empty one, which would make every go1.N false.

That subprocess costs ~300 ms on Windows, so it is contained twice. Resolution is lazymatchTag tests the go1.N shape before resolving anything, so a constraint naming no release tag never pays it; and the answer is then cached per module root, because GOTOOLCHAIN keys on the module. Both halves earn their place: the behavioral corpus is 569 separate modules, so the cache alone would still spend 569 lookups answering a question not one of those packages asks (no behavioral constraint mentions a release tag), while -stdlib reaches the lookup exactly once — sort is the only standard-library package with a go1.N gate — and GOROOT/src carries one go.mod above all 302 packages anyway. Note this does not help the type checker go2cs links in, which is still whatever release compiled it; a module whose go directive exceeds that still needs go2cs rebuilt on a newer toolchain.

Guarded by src/go2cs/buildConstraints_test.go (release tags bare/negated/compound, the legacy +build grammar, extraction precedence, loader-toolchain resolution), each assertion verified to fail against the pre-fix converter.

A blank import forces the imported package’s init to run

Go’s import _ "image/png" imports a package purely for the side effects of its init, and the language guarantees that initializer runs before the importing package’s own. A converted Go init becomes [GoInit], which csproj-template.xml aliases to .NET’s [ModuleInitializer] — the right shape and a weaker guarantee: a module constructor runs at first access to something in its module, so an assembly nothing in the program ever names is never loaded and never initializes. A blank import is by definition the case that names nothing, and the observable form is a registry that stays empty. image/gif’s writer_test.go blank-imports image/png so that png’s init calls image.RegisterFormat (image/png/reader.cs); with the import emitted as a comment alone that never ran, image.Decode had no PNG entry, and TestWriter failed with ../testdata/video-001.png image: unknown format — the package’s only failure, at 27 of 28. The same shape gates every registration-by-blank-import consumer: database/sql drivers (sql.Register), net/http/pprof (its init installs the /debug/pprof handlers), image/png and image/jpeg as decoders for anything calling image.Decode, and time/tzdata.

The blank import still emits no usingusing _ = <ns>; would hijack C#’s _ discard for the whole file (CS0118 + CS0029 on any deconstruction discard) — so it stays a comment, and the initialization is forced by a generated module-initializer hook at the top of the importing file’s class body:

import (
    _ "BlankImportSideEffects/jpeglike"
    _ "BlankImportSideEffects/pnglike"
    "BlankImportSideEffects/registry"
)
// blank import: BlankImportSideEffects.jpeglike_package (side effects only; no using emitted — a `using _` alias hijacks C# discards)
// blank import: BlankImportSideEffects.pnglike_package (side effects only; no using emitted — a `using _` alias hijacks C# discards)
using registry = BlankImportSideEffects.registry_package;

partial class main_package {

// Go runs a blank-imported package's `init` before this package's own; .NET would never
// load an assembly nothing references, so the side effects the import exists for are forced.
[GoInit] internal static void initᴛᴛblankImportBlankImportSideEffectsjpeglike() {
    builtin.initPackage(typeof(BlankImportSideEffects.jpeglike_package));
}

Four decisions make that emission what it is.

The mechanism is RuntimeHelpers.RunModuleConstructor, wrapped as golib’s builtin.initPackage(Type) so the emitted line stays readable and the mechanism lives in exactly one place. It is the explicit, spec-defined way to run a module constructor, and the runtime guarantees a module constructor runs at most once — so several blank importers of one package, or a package forced after it has already loaded, are no-ops rather than repeated init calls. Measured under Native AOT as well as the JIT: the call is AOT-safe, and under AOT the gap does not even arise (a single native image has no lazy assembly load, so every linked module’s initializers run at startup regardless) — the forced call is simply redundant there. typeof also roots the package class for the trimmer, so a trimmed publish keeps the assembly the program otherwise never names.

The hook leads the class body, ahead of the file’s own init functions, because Go orders an imported package’s initialization before the importer’s. Roslyn emits an assembly’s module-initializer calls in compilation file order and then declaration order within a file, so leading the file’s declarations is what that ordering buys — within one file it is exact. Across files of one package the order is Roslyn’s, which is the same latitude the conversion already lives with for every non-blank import (whose initializer the CLR runs lazily at first use, not in Go’s order); no converted package depends on the difference. Reproducing Go’s ordering in full would mean forcing every import eagerly, in dependency order, from a single per-assembly driver — a strictly larger change that trades startup cost (loading the whole transitive assembly closure at module init) for fidelity nothing currently needs. Deliberately deferred, not overlooked.

Exactly one hook per (assembly, imported package). Go initializes a package once per program however many files import it, and a .NET module constructor likewise runs once per assembly, so the hook belongs to the first file that names the import; later files’ blank imports of the same package emit nothing. The hook’s name is derived from the import path (image/pnginitᴛᴛblankImportꓸimageꓸpng), which makes it unique by construction — two blank imports in one file are two methods, and a shared generated name would be CS0111 — and stable across runs without a counter or a file-name mangle. Path segments are reduced to C# identifier characters, so a module path’s dots and hyphens (github.com/mattn/go-isatty) cannot break the identifier. The doubled temp marker keeps the name clear of the relocated-package-var method space (initᴛ<varname>, see Package-Level Variable Initialization Order) and the blankImport word clear of the -tests package-init hook (initᴛᴛtests).

Go’s pseudo-packages are skipped. unsafe and builtin are compiler-provided and have no initialization at all, and C is cgo. import _ "unsafe" is the //go:linkname ritual — 67 files of the converted standard library — so forcing it would be a guaranteed no-op emitted 67 times. Only real packages get a hook, which is why the converted corpus’s blast radius is three files (crypto/x509 → sha1/sha256/sha512, runtime/metrics → runtime, runtime/race → its amd64v1 variant) rather than seventy.

Only the module constructor is forced — that is exactly the package’s init functions. A package’s own package-level variable initializers are C# static field initializers on the package class, which the CLR still runs lazily at first access to that class, unchanged from every other import (and the package’s own init touching them is what triggers them). The residual case is a package whose registration is a package-level var _ = pkg.Register(…) rather than an init; closing it means additionally forcing the package class’s type initializer, which is deliberately not done here because it would eagerly run runtime’s 291 package-level initializers on behalf of runtime/metrics’s linkname-only blank import for no measured benefit. No blank import in the converted standard library registers that way.

The -tests emission carries all of this unchanged — the hook is written by the same import visitor, so a blank import in a _test.go file (which is where image/gif’s is) forces from the test assembly. Guarded by the BlankImportSideEffects behavioral test — a registry package that two blank-imported sibling packages fill from their inits, read back by an importer that never names either registrant, with the importer’s own init recording the count to prove the ordering and an unregistered name as the negative control (without the hooks the program prints count at init: 0 and three missing: lines; with them it matches go run exactly) — plus the TestBlankImportInitName and TestNoInitPseudoPackages converter unit tests, which lock the generated name’s uniqueness and the pseudo-package skip.

A NuGet-referenced standard library carries its exported metadata IN THE CONVERTER

Everything above — the imported-type-alias global using round-trip, and the foreign GoImplement records that tell a consumer its dependency’s own assembly already implements an interface — is learned by opening the referenced package’s package_info.cs and scraping the [assembly: …] lines out of it (loadImportedTypeAliases / loadPackageImplements, importOperations.go). That file is converted source, found at $(go2csPath)core/<pkg>, i.e. wherever deploy-core staged the standard library.

-recurse=nuget removes exactly that file. In NuGet mode the standard library is consumed as pre-compiled go.<pkg> assemblies, so there is no converted-source tree on disk — and there never will be, since skipping the source deployment is the whole point of the mode. The scrape then finds nothing and the converter falls through to foreignDerivedTypeAliases, which recovers some aliases from the dependency’s own Go declarations and no implement records at all. Two things go wrong, both in the CONSUMING package:

golang.org/x/sys/windows shows both at once: it Go-aliases type Errno = syscall.Errno, so the two spellings Errno and syscall_package.Errno were each recorded, resolved to the same type, and made ImplementGenerator emit the syscall_Errnoᴠerror adapter twice — CS0102 / CS0111 ×5 / CS8646. That single duplicated adapter is what made the README’s fatih/color walkthrough unbuildable under -recurse=nuget.

Note what is NOT the cause. The go2cs-gen generators are not reference-kind sensitive: a real MSBuild build hands a <ProjectReference> to the compiler as a PortableExecutableReference exactly like a <PackageReference>, and the generators read foreign type shape from symbols either way (see FindTypeSymbol). Both modes ran the same generator over the same kind of reference; the emitted C# they were given differed, because the converter’s cross-package knowledge differed.

The conversion. The exported metadata is a static, per-package property of the published assemblies, so it travels with the converter. internal/genstdlibmeta captures the <ExportedTypeAliases> section and every GoImplement record of all 302 src/core/**/package_info.cs into src/go2cs/stdlib-metadata.txt (~128 KB), which stdlibMetadata.go embeds via //go:embed. When a stdlib dependency’s package_info.cs is absent, the converter reads its recorded lines through the very same parsers (parseExportedTypeAliasLines / parseExportedPointerImplementLines / parseExportedValueImplementLines, refactored off the file read for this) and proceeds identically. The asset is generated from the same tree push-nuget.ps1 packs, so the embedded record and the published assemblies are always one commit’s output.

Reading the record is gated on PackageInfo.PublishedStdLibisStdLib && nugetRefs && !convertStdLib — and this gate is a soundness precondition, not conservatism. The record describes the converted standard library at src/core; substituting it is only correct when that is what the build actually references. Under -recurse=nuget that is guaranteed. A $(go2csPath) deploy root may hold an older or partial staging whose exported surface differs, and the stdlib self-conversion is building the assemblies being published. Both keep the derive-from-declarations fallback. Because -recurse=nuget is off by default, no other conversion path changes — CNR is byte-identical across the behavioral corpus.

The result is exact rather than approximate: converting the README’s colordemo with -recurse=nuget against an empty $(go2csPath) now emits byte-identical C# to the same app converted with -recurse against a full deploy-core stdlib root, and the NuGet-referencing solution builds with 0 errors and runs with output matching go run. syscall.Errno is not a special case — a probe returning six foreign concrete types as foreign interfaces recovered 14 imported aliases and dropped three spurious re-declarations spanning both record forms (io/fs’s PathErrorerror pointer adapter, sort’s IntSliceInterface value implement, and syscall.Errno), leaving only the one record the consumer legitimately owns (os.Fileio.Reader), exactly as the source-referencing conversion does.

Five guards (stdlibMetadata_test.go). TestStdLibMetadataAssetFileName pins the generator’s output name against the //go:embed target (and its package_info.cs constant against the converter’s), so the two halves can never write and read different files. TestStdLibMetadataInSync regenerates the asset in-process from src/core and fails on drift — a stale asset would hand -recurse=nuget the previous standard library’s records while the published assemblies carry the current ones, surfacing only as downstream C# errors. TestStdLibExportedMetadataReadsThroughPackageInfoParsers pins that the embedded lines feed the shared parsers (and that a Pointer-form record does not leak into the value implements). TestPublishedStdLibScope pins the three-way gate above. TestRecurseNuGetResolvesForeignImplements converts a module returning a syscall.Errno as an error with no converted stdlib on disk and asserts the consumer records nothing and emits no local adapter — it fails on both assertions with the record disabled.

Residual limitation. The asset is regenerated by go generate . from src/go2cs, so a change to the converted standard library must be re-banked into src/core before it reaches -recurse=nuget consumers. TestStdLibMetadataInSync makes that a test failure rather than a silent mismatch, but it can only compare against the committed tree — it cannot detect that the committed tree is itself older than the published packages.

A foreign implement record is keyed in ONE spelling, and a VALUE one is trusted only for a partial struct

The record scraped above answers one question at a cast site: does the dependency’s own assembly already implement this pair? If it does, the bare value converts implicitly and a local <pkg>_<T>ᴠ<Iface> value adapter is dead machinery. Getting the answer wrong in either direction is expensive, so both halves — the KEY and the TRUST — are stated precisely here.

The key. implementRecordKey composes <declaring package>|<C# simple type>|<pkg>_package.<Iface> and is called by BOTH sides of BOTH record sets: loadPackageImplementLines, over records parsed from a dependency’s package_info.cs, and the value arms and the foreign-pointer arm of convertToInterfaceType, over a cast being converted. That it is one function is the whole point — the two sides used to compose it independently, over different alphabets, and agreed only when the dependency’s import path was a single segment:

dependency load side use side  
io io\|noBody\|io_package.ReadCloser io\|noBody\|io_package.ReadCloser match
encoding/binary binary\|bigEndian\|binary_package.ByteOrder binary\|bigEndian\|encoding.binary_package.ByteOrder miss
image/color color\|ΔRGBA\|color_package.Color color\|RGBA\|image.color_package.Color miss
text/template/parse (ptr) parse\|ListNode\|parse_package.Node parse\|ListNode\|text.template.parse_package.Node miss
go/types (ptr) types\|TypeName\|go.types_package.Object types\|TypeName\|types_package.Object miss
image (ptr) image\|ΔRGBA\|image_package.Image image\|RGBA\|image_package.Image miss

Two divergences, and the second is easy to miss because it only shows on a collision-renamed type. (1) The INTERFACE side: a parsed record names the recording package’s own interface BARE and a foreign one whole (go.image.color_package.Color), while a cast site always renders the full namespace chain. canonicalImplementRecordIfaceName drops everything ahead of the <pkg>_package segment, so both reduce to color_package.Color; a member path under the class (y_package.Outer.Inner) survives intact. The package CLASS must stay — the simple name alone collides, and image’s Palettedimage.Image record must not satisfy a Paletteddraw.Image cast. Note that neither side is reliably the longer one, so a “strip the chain” heuristic would not do: go/types records its OWN Object fully qualified where the cast site renders it short, while text/template/parse records its own Node bare where the cast site renders it whole. Which spelling a file produces depends on its own using/alias context — which is exactly why a canonical form, and not either side’s raw text, is the key. (2) The TYPE side: a record carries the EMITTED C# name, so image/color’s RGBA (collision-renamed against its own RGBA() method) is ΔRGBA there, while the use side was naming the GO type. Both sides now reduce the emitted name.

The DECLARING-package component is what keeps a record honest. A package may record a value pair for a type declared in a THIRD assembly — image re-declared all of image/color’s models — and go2cs-gen realizes that as a local adapter class, not as the type implementing the interface. The use side names the TARGET’s package, so such a record can never satisfy a cast (image|Alpha|… against color|Alpha|…).

The trust. A record says the declaring assembly implements the pair; it does not say HOW. ImplementGenerator makes every named Go type a partial struct T : Iface that really does implement it — struct, slice ([GoType("[]Color")] partial struct Palette), map, channel, numeric ([GoType("num:nint")] partial struct ΔSignal) — with exactly one exception: a named FUNC type arrives as a C# delegate, which cannot be a partial struct, so its TypeKind.Delegate arm emits an adapter CLASS in the declaring assembly instead. valueRecordRealizesAsPartialStruct gates on the target’s Go underlying being a non-*types.Signature, at the use site where go/types can still see it. Without that gate the fix hands a bare delegate to an interface slot — CS0029 for net/http’s HandlerFuncΔHandler in expvar, net/http/cgi and three more.

The POINTER set shares the key and needs no trust gate. [assembly: GoImplement<T, Iface>(Pointer = true)] is not “the declaring assembly implements this somehow” — it is exactly the shape ImplementGenerator realizes as the public adapter class <T>ж<Iface>, so the record’s existence is the answer and there is nothing further to ask. (The delegate hazard that forces valueRecordRealizesAsPartialStruct on the value side cannot arise: a pointer record already means the adapter route was taken.) An earlier ruling kept this set’s key un-collapsed on the reasoning that matching a foreign record suppresses a LOCAL record the consumer needs; that hazard is real but it is a realization question, not a key question, and on the pointer side it does not exist at all.

One consequence had to be fixed with the key, and only the collision-renamed types reach it: a foreign type whose name is Δ-renamed resolves through a whole-TYPE global using alias (imageꓸRGBA = go.image_package.ΔRGBA), which is a single identifier rather than a path — and the adapter is a MEMBER of the declaring package’s class, so composing onto the alias names nothing (imageꓸRGBAжImage, CS0246 ×11 across five packages). The foreign-adapter arm therefore rebuilds a dotless base as the file’s package qualifier plus the type’s EMITTED simple name — image.ΔRGBAжImage, which is exactly what the declaring assembly’s generator composed (image/image.cs reads new ΔRGBAжImage(…) for its own casts).

Pointer footprint, from a whole-stdlib A/B with both roots seeded (304/304 converted per side): 31 files, 66 constructions, every one the same edit — new <pkg>_<T>ж<Iface>(x) becomes new <pkg>.<T>ж<Iface>(x), the declaring package’s own adapter — plus the 37 (Pointer = true) records that existed only to generate those local classes. Zero additions anywhere and the total adapter- construction census is unchanged at 4348, so it is a one-for-one redirection, not a removal. By declaring package: text/template/parseNode 33, go/typesObject/ΔType 20, imageImage 4, net/httpRoundTripper/ΔHandler 4, net/urlerror 2, net/textprotoerror 1, go/internal/srcimportertypes.Importer 1, go/build/constraintExpr 1. go2cs-stdlib.slnx builds 0 errors on the overlaid tree.

The pointer form’s symptom is milder than the value form’s and worth stating precisely, because it is what makes this an increment rather than a bug fix. The generated adapter’s Equals compares IжAdapter.Box by reference, so a redundant local adapter and the declaring assembly’s own one still compare equal and still alias the same object — no observable divergence was reproduced. What is wrong is duplication plus a non-deterministic dynamic type: each adapter’s module initializer calls AdapterRegistry.Register(typeof(ж<T>), typeof(Iface), …), which is first-wins, so which assembly’s class a type-assert re-wraps into depends on assembly load order. The value form’s second-identity failure (image/png’s %v, below) is the same defect one degree worse.

Footprint, from a whole-stdlib A/B with both roots seeded (302/302 converted per side): 13 files, every changed line the same edit — new <pkg>_<T>ᴠ<Iface>(x) becomes x — plus the 16 [assembly: GoImplement] records that existed only to generate those adapters. 497 constructions go away (472 of them in image/color/palette’s two palette literals); the rest of the corpus adapter census is identical count for count, HandlerFuncᴠΔHandler included. Two survivors are instructive because they are NOT this defect: color.Palettecolor.Model (5) and encoding/binary’s bigEndian/littleEndianByteOrder (79) have no record to match at all — neither package ever converts that pair itself, so nothing writes the record and the local adapter is the only realization. A pair a package satisfies but never records is a separate root, closed by the section below.

This is not merely a wasted allocation. The adapter is a second identity for one Go value: reflect and fmt see the wrapper where the Value’s own type says the wrapped struct, which is how it surfaced — image/png’s diff printing %v of a color.Color died with System.ArgumentException: Field 'R' … is not a field on the target object which is of type 'go.image_package+color_NRGBAᴠColor'. (Guarded by the ForeignValueImplementSuppression behavioral test — a sibling package at a multi-segment path that converts its own values, a collision-renamed implementer, a second implementer, and a named FUNC type as the live negative; the pre-fix converter emits five adapters where the fixed one emits the func’s alone. ValueAdapterDynamicType was its byte-identical complement — its sibling never converts, so its four adapters were real — until the declaring side began recording pairs it merely satisfies (next section), which is exactly that sibling’s shape; its assertions now prove the bare value instead. The pointer form is guarded by ForeignPointerImplementSuppression, whose sibling tone self-converts a collision-renamed *Tone and an ordinary *Plain — both must reference tone’s own adapters — against two live negatives that must keep minting their own: *Lone, a pair tone satisfies but never records, and shade.Level, an interface with the same SIMPLE name as tone.Level. The pre-fix converter emits four local adapters there where the fixed one emits the two negatives’ alone. Unit-guarded by TestImplementRecordKeyBothCompositionsAgree, TestImplementRecordKeyKeepsPackageClassDiscrimination and TestValueRecordRealizesAsPartialStruct.)

A package records the pairs it SATISFIES, not only the ones it witnesses

Every [assembly: GoImplement<T, Iface>] the converter writes comes from a cast it convertedconvertToInterfaceType records the pair it just emitted. Go satisfies an interface structurally, so a package can implement one of its own interfaces completely and never write a conversion: encoding/binary declares type bigEndian struct{} with the whole ByteOrder method set and exports var BigEndian bigEndian, with no var _ ByteOrder = BigEndian anywhere. No cast, no record — so binary_package.bigEndian was emitted as a partial struct that does not implement ByteOrder, and every consumer minted its own binary_bigEndianᴠByteOrder adapter. This is the one place where the declaring assembly implements this pair is TRUE in Go and FALSE in the emitted C#, and it is the root the section above measured but did not close.

recordSamePackageImplements (samePackageImplements.go, called from processConversion after the file visits and before writePackageInfoFile) walks the package scope and records the VALUE-form pairs the package satisfies. encoding/binary’s metadata gains:

// <InterfaceImplementations>
[assembly: GoImplement<bigEndian, AppendByteOrder>]
[assembly: GoImplement<bigEndian, ByteOrder>]
[assembly: GoImplement<littleEndian, AppendByteOrder>]
[assembly: GoImplement<littleEndian, ByteOrder>]
[assembly: GoImplement<nativeEndian, AppendByteOrder>]
[assembly: GoImplement<nativeEndian, ByteOrder>]
// </InterfaceImplementations>

and every consumer hands over the bare value, its own record and adapter gone with it — debug/dwarf’s d.Value.order = new binary_bigEndianᴠByteOrder(binary.BigEndian) becomes d.Value.order = binary.BigEndian, and crypto/x509’s crypto.SignerOpts signerOpts = new crypto_HashᴠSignerOpts(hashFunc) becomes crypto.SignerOpts signerOpts = hashFunc.

It records through convertToInterfaceType with an EMPTY expression — the record-only probe path convCompositeLit / convTypeAssertExpr / visitValueSpec already use, since every emission arm is gated on exprResult != "". That is the whole design: a synthesized pair is composed, keyed and pruned exactly as a real cast would compose, key and prune it, so no second naming path can drift from the cast site’s — the divergence that made the FOREIGN lookup miss for six weeks. Scope names arrive sorted, so the record order is deterministic across runs.

Five gates bound it, and each one is load-bearing.

The gates bind only the SPECULATIVE recorder. A pair the source actually casts is DEMANDED and still records at its cast site — promotion depth and all — so none of this narrows existing behavior.

The POINTER method set was deliberately out of scope here — types.Implements(*T, Iface) is the far larger set, its records are adapter-class existence signals with a different trust rule, and it was owed its own increment with its own measured footprint. That increment has since landed; see the next section.

Footprint, from a whole-stdlib A/B with both roots seeded (302/302 converted per side, 3690 files compared CRLF-normalized): 68 files, split evenly between metadata and code. 33 records appear across sixteen declaring packages; 31 go away — three dropped by the existing interface-inheritance prune because a newly recorded pair subsumes one a cast had recorded (flag’s textValueValue under Getter, and net/runtime’s errorStringerror under their own ΔError, which embeds it), and twenty-eight consumer-local foreign records that existed only to generate an adapter. 89 adapter constructions disappear across 34 files — exactly the census the previous section predicted, pair for pair: binary_bigEndianᴠByteOrder 43, binary_littleEndianᴠByteOrder 36, color_PaletteᴠModel 5, crypto_HashᴠSignerOpts 5. Every changed consumer line is the same edit, the adapter construction unwrapped to its argument; the full stdlib solution builds with 0 errors.

Most of the 33 new records have no consumer today — they are the rule stating what Go already says (sort’s reverseInterface, image’s RectangleRGBA64Image, debug/macho’s five Load implementers, io’s discardStringWriter), and they cost one assembly attribute each. Notably ABSENT is net/http’s HandlerFuncΔHandler: the delegate gate holds on the corpus instance that motivated it.

Guarded by the SamePackageImplementNoWitness behavioral test — a sibling ledger package that declares an exported interface and value implementers and never converts one to the other, with its negatives live rather than asserted (a named FUNC type, an unexported interface, a generic; the pointer-only implementer was a fourth until the next section made it a positive). The pre-fix converter records nothing and mints four adapters where the fixed one mints one; the delegate negative (ledger_MeterᴠMetric) is byte-identical across the fix. CrossPkgLib/CrossPkgUser and ValueAdapterDynamicType carry the same shape and re-baselined to the bare value. The realizability gate is guarded by COMPILE rather than by a golden, and by the corpus case that found it: CrossPkgUser’s rig is the depth-2 promotion, so dropping the gate puts a GoImplement<rig, Labeled> back and the suite goes red on CS1503 in the generated forwarder.

The POINTER method set records the same way, for a different contract

The section above closed the VALUE half of “the declaring assembly implements this pair is TRUE in Go and FALSE in the emitted C#” and named the POINTER half as owed. This is that increment.

Why it is not just “the same rule with a bigger set.” A value record and a pointer record are consumed differently, and the difference decides both the gates and the failure mode. A VALUE record licenses an IMPLICIT conversion: the declaring assembly’s partial struct T : Iface means a consumer hands over the bare value and names nothing. A POINTER record is an adapter-class EXISTENCE signal — Pointer = true is exactly the shape ImplementGenerator realizes as <T>ж<Iface> — and the consumer CONSUMES it by NAME, emitting new pkg.TжIface(x) where it would otherwise mint its own pkg_TжIface.

Why cast-site sourcing is not good enough, stated as the bug it caused. Every record the converter writes comes from a cast it converted, so a pair’s record lives or dies with the ONE body that happens to witness it. syscall’s three Sockaddr{Inet4,Inet6,Unix} → Sockaddr pairs are witnessed by exactly one method body, (*RawSockaddrAny).Sockaddr, and hand-owning that single function — which the blittable- mirror work has every reason to want — silently dropped all three (Pointer = true) records, after which a reconvert of net minted syscall_SockaddrInet4жΔSockaddr beside syscall’s own. Nothing failed to compile; the L10 lane found it only because it re-converted a dependent and diffed. The pointer form’s symptom is milder than the value form’s %v-over-the-wrapper crash — both adapters wrap the same box and compare equal — but it is a NON-DETERMINISTIC dynamic type: each adapter’s module initializer calls AdapterRegistry.Register first-wins, so which class a type assert re-wraps into follows assembly load order. Sourcing the record from the METHOD SET makes the pair independent of which bodies a run converts, which is the root fix rather than a rule about what may be hand-owned.

recordSamePackageImplements (the renamed recordSamePackageValueImplements) therefore asks BOTH questions of every candidate and records both forms. The pointer set is a SUPERSET of the value set, so a value-satisfied pair is recorded twice, and that is deliberate: Go’s T and *T are two dynamic types, realized as the partial struct and the adapter respectively, and dropping the pointer record for such a pair leaves a consumer’s var i Iface = &t with nothing to reference.

The gates are the same five, plus one. The added gate is the trust rule made mechanical:

The realizability gate is not merely re-asked of *T — it is TIGHTENED, and that is the second place the two forms genuinely differ. generatorCanForwardPointerMethodSet requires every interface method to resolve DIRECTLY on the type (index length 1), admitting no promotion at all, where the value bound admits one embed hop. A partial struct’s explicit implementation resolves a promoted member the way the converter’s own call sites do; the ж adapter does not. Its promoted-member arms are keyed on embedded POINTER fields (GetEmbeddedPointerHopNames), and with exactly one such field the single-hop arm takes every unbound member unconditionally — which the generator says outright, and is right to, because for a DEMANDED record that member’s promotion is what type-checked the cast. For a SPECULATIVE record it is not: the member’s true source may be a different embed entirely.

StructPointerPromotionWithInterface’s MyCustomError is the corpus instance, and the go2cs.slnx build found it rather than reasoning did. It embeds BOTH the Abser interface and *MyError; Abs is promoted from the INTERFACE, but the adapter’s lone pointer embed is *MyError, so the generated forwarder bound Abs against MyError — where the only candidate in scope was time.Abs(Duration): CS1929, in a generated file, naming time from a test about struct promotion. Depth is not the discriminator (that promotion is index length 2, which the value bound admits); the KIND of hop is, and modelling the generator’s exact hop selection inside the converter would duplicate its internals in a second place — the very drift this recorder’s design exists to prevent. So the bound is conservative in the same spirit as the value one and safe in the same way: withholding a speculative record leaves the consumer with the local adapter it already had. A pair the source actually CASTS is untouched and keeps the full promotion support the generator was built for, which is what that behavioral test guards. A named FUNC type is excluded before either question is asked, as before.

Footprint, from a whole-stdlib A/B with both roots seeded (304/304 converted per side; marker gate 54 marked files / 43 *_impl.cs companions / 0 violations on both roots): 75 files — 35 package_info.cs and 40 code — with 0 .csproj and 0 README.md moved. 184 records appear across 22 declaring packages (go/ast 96, image 17, io 14, image/color 12, database/sql 8, net 7, math/rand/v2 4, sort 3, …) and 117 go away, every one a consumer-local duplicate the declaring assembly now owns: go/parser 49, go/types 28, go/doc 8, go/printer 5 (all of them go/ast node types), net/http 4, the five debug/* + internal/xcoff readers’ io.SectionReader pairs, net/http/httputil’s io.Pipe{Reader,Writer}, and one each for sync.MutexLocker, image/color.RGBA64Color and parse.BranchNodeNode. Net corpus movement is +67 pointer records (1,071 → 1,138), not the 548 the deferral’s raw pair count suggested — most of that set was already recorded from cast sites, and the gates take the rest.

318 adapter constructions are repointed across 40 files, every changed line the same edit — new pkg_TжIface(x) becomes new pkg.TжIface(x) — which is the second-identity elimination measured: 318 sites that used to name a locally minted duplicate now name the declaring assembly’s one adapter. There is no third family; a classifier over the whole diff reports zero unclassified added lines.

Guarded by SamePackageImplementNoWitness, whose *Tally → Metric pair moved from negative to positive (the consumer now references ledger.TallyжMetric instead of minting ledger_TallyжMetric) and which gained the negative this gate needs — tick, an UNEXPORTED target whose pointer set implements the exported interface, kept live through ledger.Count and absent from ledger’s metadata. Also guarded by ForeignPointerImplementSuppression, where Lone — a pair tone satisfies and never casts — flipped the same way and is now that test’s proof that a record needs no witnessing cast, while its shade.Level negative (a same-SIMPLE-named interface in another package, which must keep its local adapter) is byte-identical across the change. Unit-guarded by TestPointerRecordIsPubliclyRealizable, TestGeneratorCanForwardMethodSetDepthBound and TestPointerMethodSetSubsumesValueMethodSet.

Acceptance witness — the L10 probe, re-run to prove the absence of what it once measured. With RawSockaddrAny.Sockaddr suppressed through manualConversionFuncs (a scratch build on each side, so the only variable is the recorder), syscall and net were reconverted into seeded roots:

  syscall’s (Pointer = true) Sockaddr records net’s six construction sites
pre-increment converter + suppression absent (all three) new syscall_SockaddrInet4жΔSockaddr(…) — locally minted duplicates
post-increment converter + suppression all three present new syscall.SockaddrInet4жΔSockaddr(…) — syscall’s own adapter

The pre-increment row is the regression exactly as L10 measured it; the post-increment row is the same probe finding nothing to report. That is what makes the hand-own safe rather than merely discouraged.

Standard-library solution file (.slnx)

A whole-standard-library run (go2cs -stdlib) also emits a Visual Studio solution — go2cs-stdlib.slnx — at the output root (-go2cspath), so the freshly converted stdlib is openable / buildable as one unit immediately after a run, rather than depending on a hand-maintained solution that drifts. It is the auto-generated counterpart of the committed src/go2cs-stdlib.slnx, and its XML mirrors the format of src/go2cs.slnx (a <Configurations> block plus <Folder>/<Project> entries, CRLF line endings, no BOM). It references:

The stdlib project list is gathered by walking the emitted core/ output tree (so it also picks up future test projects with no code change), and every project is emitted in stable alphabetical order for deterministic output. All paths are solution-relative (forward slashes) so the generated solution is portable — no absolute, machine-specific paths. The golib and go2cs-gen references use the same core\golib / gen\go2cs-gen layout the converted .csproj files already assume via $(go2csPath) (which resolves to $(SolutionDir)), so the solution locates them wherever those csproj references already resolve. The file is only rewritten when its content changes, so repeated runs are a no-op.

Recurse per-project solution file (.slnx)

A recursive end-user run (go2cs -recurse) instead emits one .slnx next to every converted .csproj (ModuleConverter.generatePerProjectSolutions), each over that anchor project plus its transitive converted dependencies + golib + the analyzer — no stdlib listed (the stdlib is referenced via $(go2csPath)core, pre-staged by deploy-core). Building the app’s own per-project solution thus builds the app and its whole converted dependency closure in one dotnet build, without the ~300-project stdlib solution. The anchor project is marked the Visual Studio default startup project (DefaultStartup="true").

Projects are grouped into three top-level solution folders that mirror the %GOPATH% layout, emitted in an enforced, deliberately non-alphabetic order:

Each member is placed by import path (isMainModulePackage — the same rule that routes a package’s output to src\ vs. pkg\), so the solution folders agree with the on-disk parallel tree regardless of the .slnx-relative path shape. An empty folder is omitted — a dependency’s own per-project solution has no src package — mirroring how the stdlib solution drops its /tests/ folder when empty. Because the three folder names are unique leaves, no folder Id attribute is emitted (unlike the namespace-nested stdlib solution, whose duplicate leaves like crypto/internal require the hashed folderID). Paths are solution-relative forward slashes, CRLF line endings, no BOM; the file is rewritten only when its content changes. Rendered by buildRecurseSolutionXML (solutionGenerator.go), guarded by TestBuildRecurseSolutionXML, TestBuildRecurseSolutionXMLSkipsEmptyFolders, and the folder-order assertions in the TestRecurseSyntheticModule integration test.

Package-Level Variable Initialization Order

Go initializes package-level variables in dependency order (spec: “within a package, package-level variable initialization proceeds stepwise, each step selecting the earliest variable … that has no dependencies on uninitialized variables”), where dependencies are resolved through function calls and function literals referenced by the initializer. The default conversion emits a package var as a C# static field with an initializer — but C# executes static field initializers in textual order within one file of the partial package class and in an undefined order across files. Three dependency shapes therefore break at runtime while compiling cleanly (the Phase-3 → Phase-4 distinction in miniature):

  1. Cross-file: syscall’s var procSetFilePointerEx = modkernel32.NewProc("SetFilePointerEx") (syscall_windows.go) reads modkernel32 declared in zsyscall_windows.go — with the wrong file order the receiver box is nil (NullReference in the type initializer, the first Phase-4 crash of any program importing os).
  2. Cross-file through a function: syscall’s var Stdin = getStdHandle(STD_INPUT_HANDLE) — the initializer calls a package function whose body reads zsyscall_windows.go’s procGetStdHandle. Same failure, invisible to a direct-reference scan; dependencies must be resolved transitively through same-package function bodies (and func-literal bodies — an IIFE initializer executes at init time), exactly as Go’s own analysis does.
  3. Same-file forward reference: var first = base + 1 declared above var base = 41 — C# reads base’s zero value (silently wrong value, no crash).

The conversion: collectMovedInitVars (converter: initOrderOperations.go) walks types.Info.InitOrder — Go’s authoritative dependency-sorted initializer list — resolving each initializer’s same-package var dependencies transitively through package function/method bodies. An initializer moves when a dependency is cross-file, a same-file forward reference, or itself moved (a moved var is only assigned in the ctor, so its dependents can no longer stay field initializers regardless of layout). A moved var is emitted as a bare field plus a tiny init method beside it in its home file (so the rendered expression keeps that file’s using aliases), and a generated package_init.cs supplies the package class’s static constructor, calling the methods in InitOrder:

// syscall_windows.go
var procSetFilePointerEx = modkernel32.NewProc("SetFilePointerEx")
// syscall_windows.cs
internal static ж<LazyProc> procSetFilePointerEx;
internal static void initprocSetFilePointerEx() { procSetFilePointerEx = modkernel32.NewProc("SetFilePointerEx"u8); }

// package_init.cs (generated)
partial class syscall_package {
    static syscall_package() {
        initprocSetFilePointerEx();
        // … every relocated initializer, in types.Info.InitOrder …
    }
}

This is correct by C#’s own initialization guarantees: all static field initializers (every partial-class file) run before the static-constructor body, so every non-relocated dependency is already initialized when the ctor runs; the ctor then applies the relocated initializers in Go’s order. Vars with no order hazard (the overwhelming majority — only 25 of the 302 stdlib packages relocate anything) keep their readable inline form. Cross-package order needs no handling: accessing another package’s static field triggers that type’s initialization first (.NET guarantees), matching Go’s imported-packages-first rule. Adding an explicit static ctor also removes beforefieldinit from the package class, giving it precise initialization semantics.

Notes: a blank (_) initializer never relocates (its value is unreadable, so its order is immaterial — it still runs as a field initializer for its side effect); the initᴛ method name composes the TempVarMarker so it cannot collide with any Go identifier; an addressed global relocates as a default-valued heap box whose ctor assignment writes through the ref property into the same box; a tuple-deconstructing spec relocates as one unit (see the next subsection). The one remaining warn-and-stay-inline fallback is a moved PLAIN var whose initializer carries a multi-value hoisted inner call (globalDeclHoist — the template.Must(template.New(…).Parse(…)) spread shape); the whole-corpus census found zero flagged occurrences of it. Guarded by the PackageVarInitOrder behavioral test (all three hazard shapes plus IIFE and moved-dependency closure, output-compared vs Go).

A TUPLE-deconstructing package var relocates as ONE unit

A package-level var a, b = f() — one multi-value call deconstructed across the names — is a single initialization step in Go: types.Info.InitOrder carries one entry for the whole spec with every name in its Lhs, so collectMovedInitVars flags all of a spec’s non-blank names together under one shared ordinal, and the emission registers one initᴛ method per spec at that ordinal — writeOrderedInitCalls needs no new bookkeeping. Until 2026-08-11 this path refused to relocate (“unsupported for tuple specs”, warn and leave inline), on a “no stdlib occurrence” premise the census falsified: crypto/internal/edwards25519’s var identity, _ = new(Point).SetBytes(…) and var generator, _ = … reach feOne and d — declared later in the same file — through the package’s own (*Point).SetBytes, so the inline field initializers ran first, identity read a null feOne, field.Subtract null-dereferenced, and the package cctor threw before any test ran (the corpus’s only whole-package casualty: 0 of 55 verdicts, restored to 52 of 55 by the relocation — the three residuals are separate pre-existing roots). Census: exactly two production occurrences on Windows (both edwards25519) and two latent on darwin (os’s executable_darwin.go); full detail in docs/phase4/FINDING-init-order-tuple-specs.md.

Two emission sub-shapes (writeMovedPackageTupleVarSpec, visitValueSpec.go), both turning every name into a bare field:

One non-blank name (the edwards25519 shape): the method assigns its component directly from the once-run call; blank siblings keep their uninitialized _ᴛNʗ fields — the call now runs in the ctor, so no blank ever carries it (from the InitOrderTupleSpecs golden, var single, _ = makeGreeting()):

internal static @string single;
internal static error _1ʗ;
internal static void initsingle() { single = makeGreeting().Item1; }

Two or more non-blank names (darwin os’s var initCwd, initCwdErr = Getwd() shape; golden: var cwd, cwdErr = fakeGetwd()): the method evaluates the call once into a method-local and assigns each non-blank component from it — the inline path’s hidden static tuple holder is unnecessary, because the method body itself sequences the call before its reads. The local reuses the holder’s minted tupleᴛNʗ name shape so it cannot collide with anything the rendered call expression references:

internal static @string cwd;
internal static error cwdErr;
internal static void initcwd() { var tuple1ʗ = fakeGetwd(); cwd = tuple1ʗ.Item1; cwdErr = tuple1ʗ.Item2; }

An all-blank spec (var _, _ = f()) never relocates — blanks are excluded from the moved set, so it keeps the inline emission where the first blank’s field initializer carries the call for its side effect. An addressed name relocates as the default-valued heap box with the method assignment writing through the ref property, exactly like the plain path. A blank in the middle of a spec simply drops out of the assignment list (the golden’s var head, _, tail = makeTrio() assigns .Item1 and .Item3). (Guarded by the InitOrderTupleSpecs behavioral test — both sub-shapes, the mid-spec blank, a plain var chained onto a moved tuple var, an addressed moved tuple var, and an order-safe inline control, output-compared vs Go — and by the converter unit test TestPackageTupleVarSpecInitOrderRelocation, which additionally pins that the refusal warning no longer fires and that an order-safe spec keeps the inline holder emission.)

Test-variant (-tests) initializers relocate too — through the erasable static-ctor hook

The same pass runs in the -tests conversion driver (the three-drivers rule): a _test.go package-level var whose initializer reads a var declared later in the file, in another file, or in the production package cross-file has exactly the same hazard — internal/fmtsort’s compareTests reads chans/ints declared 170 lines below it, so every test died in the class initializer on the default slice; encoding/gob’s basicTypes table reads production type.go vars cross-file. The relocated emission is unchanged (bare field + initᴛ<name> method beside the declaration); only the ordered-constructor site differs by variant, because a C# class gets ONE static constructor across all partial parts:

Two hard-won rules ride along: the hook name doubles the TempVarMarker (init + ᴛᴛ + tests, Symbols.PackageTestInitHookMethod) so no relocated var’s initᴛ<name> method can collide; and go2cs-gen’s PartialStubGenerator must never stub the hook — its whole design is C# erasure when unimplemented, and the generator’s NotImplementedException stub for “asm/cgo” bodyless partials detonated encoding/gob’s static ctor for every consumer of the production assembly (go/token’s serialize path) until the generator learned to skip it (guarded by PartialStubGeneratorTests.StubsAsmPartialsButNeverTheTestInitHook). The hook emission is -tests-gated, so a plain -stdlib/behavioral conversion is byte-identical — like the IP-4 csproj test-artifact exclusions, the production-file difference is intended -tests output, not drift. The relocated test files themselves ride the banked suites (fmtsort’s cctor is the operational guard). The hoisted-literal interplay keeps its conservative setting (initOrderRelocated=false for the real test emission run): suppressing test-file hoists in initializer-reachable functions is a pure allocation pessimization, never a correctness risk, and flipping it would drift every banked *_test.cs.

A CONSTANT emitted as an initialized FIELD is an initialization dependency too

Go constants are compile-time values with no initialization order at all, so types.Info.InitOrder never mentions them — and for almost every constant that stays true in C#, because a constant C# cannot declare const is emitted as a get-only property rather than a field (see Constant Values). A property re-evaluates at each read, so it can never be observed at its zero value, whatever the declaration order.

Two forms cannot be properties, because re-evaluating them would rebuild an allocation on every read: a string constant (@string, or a [GoType("@string")] wrapper such as const labelPipe label = "pipe", whose u8 literal the string-literal arc deliberately hoists to a single allocation) and a GoBigConst constant (a BigInteger.Parse). Those two stay static readonly fields with initializers, and therefore carry exactly the cross-part field-initializer ordering hazard a package var has. The relocation analysis has to supply that dependency edge itself, because Go’s own analysis has no reason to model it:

// server.go — a plain string const, so a `static readonly @string` FIELD
const TimeFormat = "Mon, 02 Jan 2006 15:04:05 GMT"
// header.go — sorts BEFORE server.go in the compile set
var timeFormats = []string{TimeFormat, time.RFC850, time.ANSIC}

Read before its initializer runs, TimeFormat is the empty @string, and net/http’s date parsing silently loses its primary format. collectRefs records package-scope *types.Const references whose emission is an initialized field (constEmittedAsInitializedField, keyed off the constant’s value kind — string always; int, float and complex only where the magnitude escapes to GoBigConst), resolved transitively through function bodies on the same memoized graph the var closure uses. A const dependency forces the move under the same two rules a var dependency does: declared in a different file, or later in the same file. A const is of course never itself relocatable, so only its declaration site matters.

The emission side needed the matching half. Relocation lived only in the non-constant initializer arm (tv.Value == nil), yet a Go-constant-valued initializer is just as order-sensitive in C#: Go folds the value at compile time, but the conversion deliberately keeps the source expression for readability, and that expression still reads the field:

// registry.go
type label string
const labelPipe label = "pipe"
// entries.go — compiled first; the initializer folds to the constant "pipe!" in Go
var pipeLabel = string(labelPipe) + "!"
// entries.cs — relocated, because the rendered expression READS the labelPipe field
internal static @string pipeLabel;
internal static void initpipeLabel() { pipeLabel = ((@string)labelPipe) + "!"u8; }

Where the edge came from, and what it kept. The defect that exposed it was regexp/syntax’s opNames — a 19-entry table index-keyed by the Op constants declared in a later file, every key read as zero, the whole table collapsing to one slot so Parse("a").Dump() printed the numeric fallback op3{a} where Go prints lit{a}. Op is a named uint8, so the property rule now removes that hazard at the source, and an over-broad predicate naming every non-const form would draw an edge for a shape that no longer has one. Scoped to the residue, the edge’s corpus footprint is two packages: internal/buildcfg relocates five initializers (GOARCH, GOOS, GO386, GO_LDSO, Version — each envOr("…", default…) over a string const in zbootstrap.go) and net/http relocates timeFormats. Both were latent zero-value const reads that compiled cleanly.

Guarded by the PackageVarInitOrder behavioral test, which carries both halves: neutering the predicate collapses pipeLabel onto an empty @string (stdout mismatch), and neutering the property rule allocates a fixed-array field at length 0 and panics on the first indexed write (exit code 2). Neither is redundant.

Compiled Library versus Source Code

One big difference between Go and many other languages is the notion of source availability. Traditionally programming languages have depended on using a pre-compiled library — both to avoid recompiling the library and to protect source as intellectual property. Go was born in an era of faster computing and prolific open source; it relies on having access to all source at compile time, including library code. Go takes advantage of this to make interesting optimizations, especially around when a structure escapes the stack to the heap. Keeping structures off the heap means they do not need to be tracked for garbage collection, and the Go compiler manages this automatically. The interesting consequence is that, for a given use of a library as source, an application structure may or may not escape to the heap depending on how it flows through the code — an optimization only possible when all source is compiled together.

Because this is a complex optimization, the converter currently assumes structures can escape to the heap except in the simplest-to-detect cases (see Pointers). A future option could distinguish optimizations targeted at a compiled library (very safe escape analysis) versus a standalone application (more aggressive). Already-converted packages are referenced as compiled libraries — the consumption model most C# developers are accustomed to — which takes two pieces, both in place: -recurse=nuget maps each imported Go package to its published go.<pkg> package reference, and the exported metadata a consumer would otherwise scrape out of the dependency’s converted source travels with the converter (see A NuGet-referenced standard library carries its exported metadata IN THE CONVERTER).

Constant Values

Go constants hold arbitrary-precision literals with expression support, and assignment of a constant to a variable happens at compile time. The converter preserves the constant value (and, in a comment, the original expression). A typed Go constant is emitted with its concrete C# type, e.g.:

public const nint MaxRetries = 3;

An untyped Go constant is emitted using a golib “untyped” wrapper type — UntypedInt, UntypedFloat, or UntypedComplex — so it can hold a value that does not fit a single primitive and can implicitly adapt to whatever numeric type its use site requires (mirroring how an untyped Go constant takes its type from context). A [GoType] struct is not a legal C# constant type, so the declaration is a get-only property rather than a field (see the next subsection for why):

internal static UntypedInt win => 100;
public static UntypedInt N => /* 11 + 1 */ 12;

A constant C# cannot declare const is a get-only PROPERTY, not a static readonly field

A Go constant has no initialization: it is a compile-time value usable from anywhere in the package regardless of declaration order. Whenever C# can say const (a primitive-typed const) that property is preserved for free. It cannot for the wrapper types above, nor for a named type, a uintptr, or a complex — and a static readonly field reintroduces exactly the initialization Go does not have. C# runs static field initializers in class-textual order (across a partial class, in <Compile>-item order), so a package-level variable declared ahead of the constant read it as the type’s DEFAULT — silently, with no diagnostic:

var fixedHuffmanDecoder huffmanDecoder    // compress/flate/inflate.go — declared FIRST
...
const huffmanNumChunks = 1 << huffmanChunkBits   // …the const it transitively needs comes LATER
type huffmanDecoder struct { chunks [huffmanNumChunks]uint32 }

As a field, huffmanNumChunks was still 0 when that variable’s new huffmanDecoder() ran its chunks = new(huffmanNumChunks) field initializer, so the decode table was allocated at length 0 where Go says 512: init filled nothing (its for off < len(h.chunks) loop never ran) and every later chunks[i] read panicked with index out of range [281] with length 0. The same trap zeroed maxNumLit for fixedLiteralEncoding’s initializer across files, emptying the fixed Huffman code table. Both are silent-correctness defects that compiled clean, and both took down every dependent package (compress/gzip, compress/zlib).

A get-only property carries no initialization at all, so declaration order cannot be observed and the JIT folds the literal at each use — Go’s semantics exactly:

internal static UntypedInt huffmanNumChunks => /* 1 << huffmanChunkBits */ 512;
public static ΔKind Uintptr => 12;                        // named-type const
internal static uintptr MaxUintptr => unchecked((uintptr)18446744073709551615);

RESIDUE — the two ALLOCATING const forms stay static readonly fields: @string (whose u8 literal the string-literal arc deliberately hoists to a single allocation) and GoBigConst (a BigInteger.Parse). A property would rebuild their value on every read. Neither can serve as an array length, so neither reproduces the failure class above — but a package-level variable initializer that reads one before its declaration point still would, so the residue is handed to the initialization-order pass, which draws a relocation edge for exactly these two forms and nothing else.

The two rules divide the problem cleanly and neither is redundant: the property form takes every numeric, named-numeric, uintptr, complex and untyped constant out of the ordering problem, and the relocation edge orders the string/GoBigConst residue that has to stay in it. Guarded together by the PackageVarInitOrder behavioral test (cross-file constants consumed by earlier-declared vars — directly, through a struct’s fixed-array field initializer, and through a constant-folded initializer over a named-string const).

Wrapper conversions are VALUE conversions in every direction. UntypedInt stores its payload as int64 bits (so a ulong-range literal like 9223372036854775808 round-trips through the same 8 bytes), and its float/complex operators originally bit-reinterpreted that payload — var fl float64 = m (m an untyped 3) produced 1.5e-323, the denormal double whose bit pattern is 3, instead of 3. The float32/float64/complex64/complex128 operators now convert by value, like every integer-direction operator always did. Because the payload can be unsigned-flavored (a beyond-int64 ulong literal such as 1 << 63 uses the uint64 constructor — bits identical to a signed -9223372036854775808), the wrapper carries a payload-kind discriminator so a float conversion keeps the uint64 magnitude and ToString() prints it unsigned. (Guarded by the UntypedIntFloatContexts behavioral test — one local const used in both int and float contexts, the 1<<63 unsigned payload, complex contexts via real/imag, and a negative payload, values verified vs Go.)

COMPARISON is a value comparison too, across payload kinds. The (payload, unsigned-flavour) pair denotes a mathematical integer, and </<=/>/>=/equality must order over that VALUE — but they compared the raw int64 bits, so a uint64 at or above 2^63 (which reads as a NEGATIVE int64) answered u < someSmallConst TRUE. That is a silent wrong answer for the whole if fastSmalls && i < nSmalls idiom the standard library uses: strconv.FormatUint(9223372036854775808, 10) took the small-integer fast path, small() truncated the negative index, and the result was "0" (TestUitoa); the varlen sibling threw on the truncated slice bound (TestFormatUintVarlen). The operators now route through one Compare helper: same flavour compares at that flavour, and mixed flavours put a NEGATIVE signed payload below every unsigned payload (otherwise both are non-negative and the unsigned reading of each is exact). Only a payload at or above 2^63 changes answer — every signed-only or in-int64-range comparison is bit-for-bit what it was. (Guarded by the BigUntypedConstComparison behavioral test’s wrapper arm — 0/999/1000/1001/1<<62/1<<63/ 1<<64-1 against a wrapper const across all six relations, the signed counter-cases, and the inlined FormatUint fast-path predicate, output-compared vs Go; the test FAILS [Output] without the fix.)

The wrapper carries 64-bit shift operators so a still-wrapped untyped shift keeps Go’s width. UntypedInt defines << and >> (an int count) returning UntypedInt, shifting the int64 payload. Without them, a wrapper-typed untyped constant shifted by a non-constant count bound through the implicit UntypedInt → int conversion — a 32-bit int shift, which both masks the count to its low 5 bits and truncates the payload to 32 bits — whereas Go shifts at the type the untyped operand assumes from context (typically the enclosing uint64). math.Frexp corrupted on exactly this: x |= uint64((-1 + bias) << shift) (bias a package-level UntypedInt const, so the compound -1 + bias stays UntypedInt; shift a runtime int, up to 52) emitted ((-1 + bias) << (int)(shift)) and computed 1022 << (52 & 31) = 1022 << 20 instead of 1022 << 52, scrambling the exponent field of the assembled float64. The operators keep the shift a 64-bit long << int returning UntypedInt, so it composes with the surrounding (uint64) conversion and reproduces Go’s value. Note this fires only for a compound untyped operand (bias - 1): a bare-identifier operand (bias << n) is cast to the shift’s context result type at the use site first, so it never reaches the wrapper operator. (Guarded by the UntypedIntWideShift behavioral test — package-level UntypedInt consts bias/bits, the compound bias - 1 left-shifted and bits - 1 right-shifted by runtime counts 52/40/33 (all > 31, so a 32-bit narrowing would mask them), values verified vs Go.)

Float constant values are emitted exactly. The emitted value of a float-kind constant declaration is never go/constant’s Value.String() — that is a shortened human-readable form (~6 significant digits), and using it silently truncated the compiled value while the exact literal survived only in the /* … */ comment (math cbrt’s C = /* 5.42857142857142815906e-01 */ 0.542857). The emission prefers the Go source literal verbatim when it is also valid C# syntax — decimal floats including exponents and _ digit separators overlap C# exactly, and a unary-minus form (-7.05306122448979611050e-01) carries its sign — which also elides the now-redundant original-expression comment (the emitted value is the original). When no single valid literal exists — a folded constant expression (19.0 / 35.0), or a Go-only literal form (hex float 0x1p-2, trailing-dot 5.) — the value emits as the shortest round-trip form (strconv.FormatFloat 'g'/-1) of the constant converted at the declaration’s width (bitSize 32 for a float32-typed const, so the f-suffixed single parses with the same one rounding Go applies; 64 otherwise):

const (
    C              = 5.42857142857142815906e-01 // 19/35 = 0x3FE15F15F15F15F1
    D              = -7.05306122448979611050e-01
    folded         = 19.0 / 35.0
)
internal static readonly UntypedFloat C = 5.42857142857142815906e-01;
internal static readonly UntypedFloat D = -7.05306122448979611050e-01;
internal static readonly UntypedFloat folded = /* 19.0 / 35.0 */ 0.5428571428571428;

A beyond-float64 value still routes to the GoBigConst (BigInteger) overflow path unchanged. (Guarded by the UntypedConstDefine behavioral test — package-level and function-local high-precision consts printed and compared against Go, which fails with the truncated 0.542857 emission; SortArrayType additionally locks the verbatim forms 1.0f/3.14e100.)

A FUNCTION-BODY float const referencing a named untyped-float const folds the same way (2026-07-18). The exact-emission above is a declaration rule; the identical double-rounding hazard exists for a compile-time float constant computed in a FUNCTION body that references a named untyped-float const (math.Pi, math.Ln10 — each emitted as a golib UntypedFloat wrapper already rounded to float64). Left as runtime C# arithmetic, float64(100000 * Pi) becomes (float64)(100000D * Pi) and rounds a SECOND time — 314159.2653589793, a ULP below Go’s single arbitrary-precision fold 314159.26535897935 (math’s TestLargeCos was fed a −1-ULP argument; the trig algorithm itself is bit-exact), and 1 / Ln10 in Log(x) * (1/Ln10) the same. foldedNamedFloatConstLiteral emits the Go-folded value as a /* <expr> */ <literal> at the RESOLVED float width, at two sites: a float64(…)/float32(…) type CONVERSION (convCallExpr, restricted to a basic float target so a named float type keeps its [GoType] wrapper path) and a computed-const OPERAND being cast to a concrete float in a binary expression (convBinaryExprCore, reusing the sibling’s resolved type). The width is honored exactly — a float32 target rounds the exact constant STRAIGHT to float32 (exactFloatText’s constant.Float32Val), never through a float64 intermediate, which would double-round differently than Go’s single round-to-float32. Gated to a COMPUTED float const that references a named untyped const: a bare named-const reference already renders as a single-rounded wrapper, and a pure-literal float const (1.5 * 2.0, no named ref) computes exactly in C# double and keeps its readable operator form. Cleared math’s TestLargeCos/Sin/Tan/Sincos + TestLog10 (68 → 73 / 77) by folding 34 sites across the sin/tan/atan/erf/jN/lgamma/pow families; the emission is a no-op on the behavioral corpus (nothing there exercises the pattern). (Guarded by the NamedConstFloatFold behavioral test — a float64(100000*myPi) conversion, its float32 counterpart, and a 1/myLn10 typed-float64 operand, output-compared vs Go.)

Function-local untyped constants TIGHTEN to their single concrete use type. A per-function analysis pass (performUntypedConstAnalysis) resolves every use of each function-local untyped numeric constant through go/types: when ALL uses record the SAME concrete basic type — and none participates in constant folding — the declaration emits at that type (with C#’s const keyword where legal: the primitive aliases; native-int/uintptr values fall to the existing static readonly/unchecked demotions), and every cast the wrapper made necessary (the bitwise/arith operand casts, the append/deferred-call element casts, the 32-bit-and-wider shift retype) is skipped as redundant. One cast is kept because it is value-changing, not wrapper-driven: a tightened const of a sub-int32 type (int8/int16/uint8/uint16) as the LEFT operand of a non-constant shift keeps the width retype — C# promotes a narrow shifted operand to int, so without (byte)(cb << (int)(k)) Go’s wraparound at the declared width is lost (const cb = 200; b + cb<<k → Go wraps 200<<1 to 144, byte width, result 145; the promoted C# shift computes 400 → 401). The emitted code otherwise reads like the Go source — math cbrt:

const (
    C = 5.42857142857142815906e-01 // 19/35 = 0x3FE15F15F15F15F1
    G = 3.57142857142857150787e-01 // 5/14  = 0x3FD6DB6DB6DB6DB7
)
s := C + r*t
t *= G + F/(s+E+D/s)
const float64 C = 5.42857142857142815906e-01; // 19/35     = 0x3FE15F15F15F15F1
const float64 G = 3.57142857142857150787e-01; // 5/14      = 0x3FD6DB6DB6DB6DB7
var s = C + r * t;
t *= G + F / (s + E + D / s);

The guards are deliberately conservative — any doubt keeps today’s Untyped* wrapper form:

A tightened constant composes with the exact-float emission above (the cbrt literals round-trip to their documented bit patterns, e.g. C0x3FE15F15F15F15F1), with the iota initializer (a position-0 = iota tightened to nint emits golib’s constant bare — see the bare-iota rule below; any other tightened type keeps the folded value with the /* iota */ comment), and with a float-KIND value under an INTEGER tightened type — const infinity = 1e6 (go/printer; 1e6 lexes as a float literal) used only in int contexts emits the integer form const nint infinity = 1000000;, since a C# 1e6 double literal has no implicit conversion to nint and the tightening pass guaranteed integral representability. (Guarded by the UntypedConstDefine behavioral test’s tightenGuards — single-type/append/defer/shift-operand uses tighten, mixed-type/const-feeding/folding uses keep the wrapper, and the narrow byte/int16/uint16 shifted consts keep the width retype (145, not 401), all output-compared vs Go — and by BitwiseUntypedConst, whose local signBit = 1 << 63 now emits const uint64 with the (uint64) operand casts dropped; ConstShadowsParam locks the shadow-rename interplay, its folded int64(ns) uses staying untightened.)

A complex constant emits a complex VALUE, rendered from its two exact halves

A COMPLEX-kind constant is emitted as a real complex value built from its real and imaginary parts, each rendered by the same exact-float machinery a float const uses (exactFloatText) and recombined in the postfix .i() form convBasicLit already emits for written imaginary literals. Because complex128 is System.Numerics.Complex and complex64 is a golib struct — and C# forbids const of a library struct (CS0283) — the declaration is static readonly, the same demotion uintptr takes:

const (
    cRational   = 5.5 + 1.5i
    cNegImag    = 2.25 - 0.75i
    cPureImag   = 3i
    cWideEnough = 1.5e308 + 1.0e307i
    cFolded     = (1 + 2i) * (3 + 4i)
)
const c64 complex64 = 1.5 + 2.5i
internal static readonly UntypedComplex cRational = /* 5.5 + 1.5i */ 5.5D + 1.5D.i();
internal static readonly UntypedComplex cNegImag = /* 2.25 - 0.75i */ 2.25D + -0.75D.i();
internal static readonly UntypedComplex cPureImag = /* 3i */ 3D.i();
internal static readonly UntypedComplex cWideEnough = /* 1.5e308 + 1.0e307i */ 1.5e+308D + 1e+307D.i();
internal static readonly UntypedComplex cFolded = /* (1 + 2i) * (3 + 4i) */ -5D + 10D.i();

internal static readonly complex64 c64 = /* 1.5 + 2.5i */ 1.5F + 2.5F.i();

The receiver’s F/D suffix selects the golib i() overload (i(this float)complex64, i(this double)complex128) exactly as a written literal’s does, and each half’s implicit float→complex conversion closes the +. A ZERO real part renders as the bare imaginary literal (3D.i() — the Go source form of 3i); a NEGATIVE imaginary part composes as written, because member invocation binds tighter than unary minus, so 2.25D + -0.75D.i() is 2.25 + −(0.75·i).

Why the halves are tested individually. Representability was previously decided by handing go/constant’s Value.ExactString() to strconv.ParseComplex. That text is the parenthesized RATIONAL form — 5.5+1.5i is (11/2 + 3/2i) — which is neither C# syntax nor a form ParseComplex accepts (its grammar is Go literal syntax: no parentheses, no spaces around the sign, no p/q). The test could therefore never succeed: every complex constant, however ordinary, was classified as beyond-complex128 and emitted through the GoBigConst arm — whose BigInteger.Parse cannot represent a complex at all. strconv’s atoc_test.go (const want = 1.5e308 + 1.0e307i, a value that fits complex128 with room to spare) failed to compile on c != want (CS0019, Complex vs GoBigConst). Each half is now rendered and range-tested on its own at the declaration’s element width; only a genuinely unrepresentable value keeps the GoBigConst arm, and that emission now warns, because it is knowingly lossy.

A FUNCTION-LOCAL untyped complex const additionally tightens to its single concrete use type (the tightening pass above), which the wrapper form cannot substitute for: UntypedComplex converts implicitly to and from complex128, so comparing a wrapper-typed const against a complex128 is AMBIGUOUS (CS0034) — atoc_test’s TestParseComplexIncorrectBitSize is exactly that shape, and it emits complex128 want = …. (Guarded by the ComplexConstContext behavioral test — rational halves, a negative imaginary part, a pure imaginary, the beyond-1e308-real strconv shape, a folded complex expression, a complex64-typed const, and a function-local const, all output-compared vs Go.)

A const initialized by exactly the builtin iota emits golib’s constant bare when it can express the value. golib’s builtin declares public const nint iota = 0 (golib/builtin.cs), so the initializer emits as bare iota — instead of the folded comment form /* iota */ 0 — only when BOTH halves of that declaration match: the folded group-position value is 0 (position 0 of the Go const group) AND the emitted C# type accepts golib’s nint constant, i.e. the emitted type is nint (an explicit Go int type, or a function-local untyped const tightened to it) or the UntypedInt wrapper (implicit from nint). Everything else keeps the folded form: a LATER group position folds to a value golib’s constant cannot express (x = iota at position 1 emits /* iota */ 1 — on the UntypedInt path a bare iota there would even compile, silently at the WRONG value), and any other emitted type — named wrappers (ΔKind Invalid = /* iota */ 0), other widths (int64) — keeps /* iota */ N rather than casting golib’s nint. (No current emission path casts an in-range position-0 const, so no (T)iota form exists; should one ever require a cast anyway, the cast would wrap iota rather than the folded value.) The identifier must resolve to the universe iota — a user-shadowed iota keeps the folded value. From compress/flate’s huffmanBlock states:

const (
    stateInit = iota // Zero value must be stateInit
    stateDict
)
const nint stateInit = iota; // Zero value must be stateInit
const nint stateDict = 1;

(Guarded by the IotaEnum behavioral test — position-0 bare iota at explicit int and via local tightening, the int64 mismatch both explicit and tightened, and later positions on both the nint and UntypedInt paths; the untyped later-position rawOne = iota case also locks the value fix — the prior emission referenced golib’s iota (0) for a position-1 constant (1) — and the named-wrapper enum stays folded; all output-compared vs Go.)

A Go untyped float constant defaults to float64, so its C# literal carries the double suffix D — not F — regardless of whether the value happens to fit in float32. (Emitting F whenever the value fit would make z := 1.0 a float, breaking later float64 arithmetic with CS0266.) A literal in an explicit float32 context keeps F:

z := 1.0           // untyped float -> float64
var f float32 = 2.5 // float32 context
var z = 1.0D;
float32 f = 2.5F;

The F-vs-D decision needs one more step inside a constant expression: go/types resolves the contextual type on the outermost constant expression only (its updateExprType deliberately never descends into constant operands — they never materialize at runtime in Go), so the inner literals of var b float32 = -3.5, of complex(2.5, -3.5) in a complex64 context, or of -(1.5 + 2.0) stay recorded untyped float and would fall back to the D default — emitting -3.5D where C# needs -3.5F (no implicit double→float32/complex64 conversion: CS0266/CS0019). Since the emitted C# preserves the operand structure, the converter re-propagates the resolved type down the constant shapes go/types dropped it from — parens, unary +/-, arithmetic binary operands, and the complex/real/imag/min/max builtin arguments, each mapping the context appropriately (a complex64 result makes complex(…)’s arguments float32; a float32 result makes real(…)’s argument complex64) — via markUntypedConstContexts (untypedConstOperations.go), which the literal emitter consults when the literal’s own recorded type is untyped:

var c64 complex64 = complex(2.5, -3.5)
var c64b complex64 = 2.5 - 3.5i
var a, b float32 = 2.5, -3.5
complex64 c64 = complex(2.5F, -3.5F);
complex64 c64b = 2.5F - 3.5F.i();
float32 a = 2.5F;
float32 b = -3.5F;

An imaginary literal is emitted in POSTFIX form — Go 3.5i becomes 3.5D.i(), the closest C# rendering of the Go literal — via golib extension methods on the suffixed real literal. The receiver suffix drives the overload choice: …F.i() (i(this float)) returns a complex64 and …D.i() (i(this double)) a complex128, so the suffix follows the literal’s resolved complex type per the same propagated context — replacing the earlier fits-in-float32 heuristic, which routed 0.1i in a complex128 context through complex64 and silently lost precision ((double)(0.1f) != 0.1). The postfix form exists because a bare i(…) call is poisoned by Go’s single most common identifier: a local or parameter named i in scope binds the bare call instead of the using-static golib helper (encoding/gob encComplex’s i *encInstr parameter, c != 0+0i → CS0149; C# block-scope rules make even a later-declared local poison an earlier bare call, CS0135/CS0844). Member access cannot bind a local, needs zero scope analysis, and — since the F/D suffix is emitted unconditionally — the receiver always lexes as a real literal (0D.i(), .25D.i(), 1e2D.i() all parse; a suffixless 3.i() would not). Member invocation also binds tighter than unary minus, so -3.5D.i() is -(3.5i) exactly as in Go, down to the negative-zero real part Go prints as (-0-3.5i). The prior solution was the class-qualified builtin.i(3.5D) — equally shadow-immune, replaced for readability; the static call form remains valid on the this-modified overloads. (Guarded by the UntypedConstFloatContext behavioral test — the shapes above plus nested parens, quotient operands, min/max, real/imag round-trips, and a named-float32 context, values verified vs Go — by ComplexImaginaryShadow — the gob-shaped shadowing parameter — and by ComplexFormat — complex printing round-trips.)

A golib companion to the above: UntypedFloat’s conversions to a complex type are EXPLICIT, not implicit. An untyped float constant emits as a golib UntypedFloat (see the untyped-wrapper section), and multiplying it into complex arithmetic — Go’s 1i * math.Pi, which the imaginary rule renders 1D.i() * math.Pi (Complex * UntypedFloat) — bound ambiguously while UntypedFloat converted implicitly to both float64 and complex128: the complex operand could bind as Complex * double (untyped → double) OR as UntypedFloat * UntypedFloat (the complex → UntypedFloat implicit conversion), and C# prefers neither (CS0034). The fix keeps UntypedFloat’s float↔complex relationship explicit in both directions (UntypedFloat.cs), so such arithmetic resolves cleanly to Complex * double — mathematically identical, since the untyped operand is a real value. This is compile-time only (an untyped float already converts implicitly to its natural float64; the widening to complex is the rarer direction, where the converter emits an explicit cast). Adding Complex-typed operators to UntypedFloat instead was rejected — it reintroduces ambiguity (UntypedFloat / int then matches both UntypedFloat op UntypedFloat and Complex op UntypedFloat via int’s dual conversions). This unblocked math/cmplx’s example build. (Guarded by the same UntypedConstFloatContext test’s 1i * gPi / gPi * 2i / 1i + gPi lines — a package-level untyped const, so the operand emits as UntypedFloat; the pre-fix golib fails these at compile with CS0034.)

An untyped INT literal resolved to a floating type takes the same F/D suffix (2026-07-18). The propagation above marks the integer argument 0 of complex(0, gHalfPi) with the complex128 element context (float64), but convBasicLit’s int-literal arm formerly ignored that mark and emitted a bare 0 (a C# int). golib’s complex builtin has both a complex(float32, float32) → complex64 and a complex(float64, float64) → complex128 overload, and C# rates the int → float argument conversion better than int → double — so complex(0, gHalfPi) (where gHalfPi is a package-level untyped float, implicitly convertible to either width) bound the complex64 overload and recomputed gHalfPi at float32: math/cmplx’s Atanh of an infinite input returned float32(π/2) = 1.5707963705062866 where Go’s complex(0, math.Pi/2) is float64 π/2 = 1.5707963267948966. The int-literal arm now renders a context-resolved literal at its float type (complex(0D, gHalfPi) → the float64 overload), exactly as the float and imaginary arms above already do; an int has no fractional part, so its exact digits plus the F/D suffix suffice. A complex64 context is unaffected — the float32 overload is the intended one there. The directly-typed float case (a []float64{74, …} element) is the companion rule immediately below. One operand kind is deliberately excluded: an integer-kind operand of a / is left unmarked, because Go integer-divides untyped-int operands even in a float context (7 / 2 is 3, then 3.0 — not 3.5), so pushing a float type onto it would silently switch C# to float division. A float-kind operand of the same / (math.Pi / 2, already a float division) keeps the context and its D suffix; the exclusion is per-operand (isIntegerKindConstExpr in the QUO arm of propagateUntypedConstContext), matching the exact-rational reasoning that already bars an integer context from crossing /. This cleared math/cmplx’s TestAtanh. (Guarded by the ComplexConstContext behavioral test — complex(0, gHalfPi)’s imaginary part as float64 π/2, plus 7 / 2 == 3 and complex(7/2, 0)’s integer-quotient real part, vs Go.)

The mirror case: an IMAGINARY literal resolved to a REAL float type emits its real part, NOT .i() (2026-07-24). Go permits an untyped complex constant whose imaginary part is ZERO (0i, value 0) to convert to a float parameter: complex(math.NaN(), 0i) — internal/fmtsort’s sort_test.go complex128-key map — where complex()’s second parameter is float64. go/types records the literal’s type as that float64 (the converted type), and its REAL part (0) is what must be emitted. convBasicLit’s token.IMAG arm formerly emitted .i() unconditionally, producing 0D.i() — a System.Numerics.Complex where the golib complex(double, double) overload wants a double, so C# reported CS1503 on BOTH arguments (doublefloat on arg 1, Complexfloat on arg 2, as it fell back to the complex(float, float) candidate). The arm now checks the RESOLVED type: when it is a float (types.IsFloat), the real part is rendered as a plain D/F-suffixed literal (0D) exactly as the float and int arms do; only a complex resolved type takes the .i() form. Only 0i can reach this arm — a nonzero imaginary constant is not representable as a real float, so go/types never records one with a float type — but the emission is driven off the resolved type, not that fact. This was the sole compile blocker for internal/fmtsort. (Guarded by the resolveBuildTags/convBasicLit converter unit coverage and the ComplexConstContext behavioral test’s complex(_, 0i) real-context line, values vs Go.)

A DIRECTLY float-typed int literal takes the suffix too — and an overflowing one MUST (2026-07-20). The rule above keys off the propagated context (untypedConstContexts), which the propagation walk records only for the constant shapes it descends — parens, unary sign, arithmetic/shift operands, and the complex/min/max/real/imag builtin arguments. A great many int literals reach a float type by a route the walk never touches: go/types types them directly as a non-untyped float — a float64/float32 composite-literal element, a typed const, a function argument, a return value, or an assignment RHS. For those, info.Types[lit].Type is already float64/float32 (not untyped int), so no context mark exists and the int-literal arm formerly fell through to the ordinary integer emitter — a bare C# integer literal. For a small value that merely bound an int-typed overload where a float one was meant (the same latent hazard as the propagated case); for a large value it was a hard compile error. strconv’s ftoa_test.go puts 123456789123456789123456789 in a float float64 struct field; the bare 27-digit literal overflows every C# integral type — error CS1021 “Integral constant is too large” — which was the sole blocker stopping the whole strconv test host from compiling in Phase 4. intLiteralFloatKind (convBasicLit.go) now consults both routes: the literal’s directly-resolved non-untyped float/complex type first (a named type over float64 resolves through Underlying to its float64 kind, matching the FLOAT arm), then the propagated context. Either way the int-literal arm emits the same F/D-suffixed literal. The Go source digits are preserved verbatim whenever they also form a valid C# real literal (the visually-similar goal) — 123456789123456789123456789D is a valid C# double literal that rounds to the same float64 the bare form overflowed on, so the emitted digits still read like the source. A radix-prefixed or legacy-leading-zero form cannot survive a pasted suffix (0x10D is the C# hex integer 269), so those re-render as the folded constant’s exact decimal digits — the reuse of isValidCSharpRealLiteral (which already rejects those forms for the FLOAT arm) gates the choice. This also makes the previously-inconsistent element pair consistent: []float64{74, -784} now emits 74D, -784D (the negated sibling already rendered -784D via the propagated route). Value-identical throughout; the behavioral A/B footprint was 15 projects, every changed line an integer-form literal in a float/complex slot gaining a D/F. (Guarded by the IntFormFloatConst behavioral test — an overflowing 123456789123456789123456789 and a small 33909 in a float64 field/var/return, a float32 field, and a decimal-form control that stays byte-identical, output-compared vs go run; unfixed, the overflowing literal fails to compile with CS1021.)

Go-only float literal forms re-render as decimal. The suffix decisions above choose what type a literal is; independently, its text must be a form C# can parse. Both literal arms emit the Go source text verbatim whenever C# shares the form — the same visually-similar goal that keeps 0x4000 from flattening to 16384, so 1.5e-3 stays 1.5e-3D — but two Go float forms have no C# spelling and must re-render as the shortest round-trip decimal (strconv.FormatFloat 'g'/-1) of the go/types-folded constant:

Go Was emitted Now
0x1p-2 0x1p-2D — CS1002, C# has no hex-float syntax 0.25D
2. / 1.e2 2.D / 1.e2D — C# requires digits after the point 2D / 100D
0x10i builtin.i(0x10D)silently 269 builtin.i(16D)

The imaginary row is the dangerous one: 0x10D is a valid C# hex integer literal, so the pasted-on suffix changed the value with no diagnostic rather than failing to compile. An imaginary literal’s mantissa is matched against constant.Imag of its folded (complex) value, never the whole constant.

The re-render rounds the exact constant straight to the literal’s resolved width (constant.Float32Val for a float32/complex64 context), never float64-then-narrow: var j float32 = 0x1.0000010000000000001p0 is 1 + 2⁻²⁴ + a residue, so double rounding lands exactly halfway and ties-to-even down to 1, while Go’s single rounding sees the residue and rounds up to 1.0000001. These forms are absent from the non-test stdlib corpus but routine in Go’s own _test.go files (the math tests) and in user code. The shared predicate (isValidCSharpRealLiteral) is the same one the constant-declaration path above uses; it also rejects the Go-only integer-mantissa radix forms an imaginary literal can carry — octal 0o123i, binary 0b101i, and legacy leading-zero 0123i/0_123i (octal-flavored source C# would re-read as decimal) — which re-render as their exact decimal value (83D.i(), 5D.i(), 123D.i(); guarded by the exotic-mantissa cases in ComplexFormat). (Guarded by the GoOnlyFloatLiteralForms behavioral test — hex floats, trailing-dot and 1.e2 forms in float64/float32 contexts, hex-float/hex-integer/trailing-dot imaginary literals in complex128/complex64 contexts, the double-rounding case above, and decimal controls proving verbatim round-trip; values verified vs Go.)

A native-sized integer constant (nint/nuint, including the uintptr alias) whose value does not fit a C# constant of that type — e.g. const MaxUintptr = ^uintptr(0) (= 0xFFFFFFFFFFFFFFFF), a ulong literal that needs a non-constant nuint conversion — cannot be a C# const (CS0133/CS0266). It is emitted as static readonly with an unchecked cast instead (small native-int consts like const nint iota = 0 stay const):

public static readonly uintptr MaxUintptr = /* ^uintptr(0) */ unchecked((uintptr)18446744073709551615);

The same unchecked cast is emitted for a named constant declared over a wide unsigned underlying whose folded value overflows int32 — const unknownClass = ^Class(0) (x/text/unicode/bidi, type Class uint) and const _m = ^Word(0) (go/constant via math/big, type Word uintptr) both fold to the all-ones literal 18446744073709551615 (a C# ulong), which has no implicit conversion to the [GoType] wrapper struct (CS0266). The native-int-const detection, which previously fired only for a uintptr underlying, now also fires for uint/uint64 underlyings, so the const emits unchecked((Class)18446744073709551615). A small named const stays uncast (const c = Class(5)Class(5), an ordinary in-range constant conversion) — the cast is added only when the value is out of int32 range, so no other named-const emission churns. (Guarded by the NamedNumericConstCast behavioral test — a beyond-int32 ^Named(0) over uint and over uint64 plus a small in-range control, values verified vs Go; shared root, cleared go/constant and bidi one error each.)

uintptr is a DISTINCT golib struct (golib/uintptr.cs), not an alias of System.UIntPtr: Go’s uint and uintptr are distinct types (both may appear in one type switch; %T reports them differently; conversion between them is explicit), and the historical alias erased that identity — type switches collided (CS8120), %T lied, and overloads could not distinguish them. The struct holds a single public mutable nuint Value field (PascalCase — it is public so Interlocked/Volatile seams can target the inner storage; the intrinsics cannot take a ref to a user struct) and carries the full operator surface so uintptr-typed expressions KEEP the type. The conversion matrix is empirically tuned to C#’s user-defined-conversion candidate rules (encompassing counts only STANDARD conversions, so nothing ever chains two user-defined operators; a PARTIAL outbound operator set is unstable — undeclared targets see multiple viable std-hop candidates, CS0457): implicit both ways with nuint plus implicit from smaller unsigned/char/UntypedInt; explicit inbound from signed types and uint64; the FULL exact outbound matrix (all integer widths + float32/float64 + unsafe void*). Knock-ons handled with it: const uintptr is illegal C# (user struct) so every uintptr const emits static readonly; a uintptr-typed switch tag/label can never be a constant/relational pattern (CS9135) so those switches use the if-else == form; wrappers over uintptr ([GoType("num:uintptr")]) gain generated nuint/UntypedInt bridges; generic-math-constrained golib helpers (unsafe.Add/Slice/String) gain non-generic uintptr overloads; and the manual managed-referent types declare direct uintptr bridges (token out, panic-on-nonzero in).

Numeric literal formatting is preserved wherever Go and C# syntax overlap: hex (0x4000), binary (0b1011), and decimal literals — including _ digit separators — emit with their original source text (0x4000 never flattens to 16384), keeping bit masks and addresses recognizable; required U/UL/L suffixes and casts compose with the preserved text (0xFFFFFFFFU). Go-only forms re-render as decimal: 0o… octal has no C# syntax, and a legacy leading-zero octal (0755) would silently re-bind as decimal 755 in C#.

A beyond-MaxInt64 integer literal in a uint64 context emits a plain UL literal. The emitter classifies an INT literal by parsed range, and a value above int64 (representable only unsigned — the -Inf bit pattern 0xFFF0000000000000, ^uint64(0)) previously always emitted (nuint)0x…UL. That prefix is the bridge needed when the literal’s resolved type is Go uint/uintptr (C# nuint — a bare ulong literal has no implicit conversion to it, CS0266, while the non-constant unchecked (nuint) conversion compiles), but in a uint64 context it is spurious: semantically wrong for a 64-bit target type and value-truncating on a 32-bit platform — math.Float64frombits(0xFFF0000000000000) emitted Δmath.Float64frombits((nuint)0xFFF0000000000000UL) while the int64-range 0x7FF0000000000000 emitted clean (the signed branch already consulted the resolved type). The emitter now checks the literal’s resolved underlying type: uint64 — including a named type over uint64, whose [GoType] wrapper converts implicitly from ulong — takes the plain 0xFFF0000000000000UL; native-width unsigned targets keep the (nuint) cast. This also cleans the same pattern from stdlib constant tables on the next regen (crypto/sha512’s K, crypto/des masks, nistec field elements). (Guarded by the MathFloatBits behavioral test — ±Inf bit patterns as uint64 arguments plus var-decl, comparison-operand, and binary-mask contexts, values verified vs Go; the BitwiseUntypedConst/NamedIntSignednessConv/ShiftPrecedenceUnsigned goldens re-baselined to the cast-free form, and LargeUintptrConst pins the native-width path unchanged.)

A constant expression whose SUBexpression overflows the target type narrows once at the whole expression. Go evaluates constant arithmetic in arbitrary precision and requires only the FINAL value to be representable in the target type — a subexpression is free to overflow it, so []int32{1<<31 - 1} is legal Go even though the inner shift is 2147483648. C# has no such rule: it would compute the operators in int and overflow at compile time (CS0220), which is why an out-of-int32-range constant subexpression FOLDS to a C# long literal (2147483648L) in the first place. That fold widens the WHOLE element rendering to long, and long converts implicitly to none of the narrower integer targets — so the emission must narrow back exactly once:

Go Was emitted Now
[]int32{1<<31 - 2} 2147483648L - 2 — CS0266 (int32)(2147483648L - 2)
[]uint32{1<<32 - 1} 4294967296L - 1 — CS0266 (uint32)(4294967296L - 1)
[]uintptr{1<<40 + 1} 1099511627776L + 1 — CS0266 (uintptr)(1099511627776L + 1)
bits & (1<<52 - 1) (uint64) bits & (4503599627370496L - 1) — CS0019 bits & ((uint64)(4503599627370496L - 1))

The narrowing applies to every integer target EXCEPT int64, whose C# long already is the widened width, and it fires at the emission itself — in the parenthesized (type)(…) form wholeExprIsCastOfType recognizes — so it reaches composite elements, arguments, and comparison operands, not only the assignment position the sibling nativeIntConstCastType covers (which keeps handling the out-of-int32-range values folded whole; the cast strings match, so neither re-wraps the other). Two scope restrictions carry over from that sibling: at least one OPERAND must itself fold to a long literal — a bare 1 << 40 (both operands small) emits as a 32-bit 1 << (int)(40) whose count C# MASKS to 8, and casting that would convert a loud error into a silently wrong value — and the whole value must be int64-exact, so a uintptr past int64 range keeps its visible error rather than being masked. Requiring a folded operand also guarantees the non-folded operands compute exactly, since a shift is only left unfolded when its value fits int32, which bounds its count below 32. A subexpression that stays inside int32 therefore keeps its readable operator form (1<<20 + 1(1 << (int)(20)) + 1), and a whole value already past int32 still folds outright (1<<63 - 19223372036854775807L).

Corpus effect: this repaired latent ulong-versus-long mismatches across crypto/aes, crypto/cipher, database/sql/driver, math/big, net/http, runtime, strconv, sync, and vendored chacha20poly1305, and made math/rand’s Int31n compute in uint32 exactly as Go does (it previously computed the same value in long). The 1<<31 - 1 / 1<<63 - 1 idiom is pervasive in Go’s own _test.go files, where the shape is a hard compile blocker. (Guarded by the ConstSubexprOverflow behavioral test — int32/int16/uint32/uint64/uintptr/int elements, the int64 no-cast case, in-range controls, and assignment/explicit-conversion/argument positions, values verified vs Go.)

The narrowing root can be a UNARY node, and the widening fold can be arbitrarily deep (2026-07-25). Two shapes escaped the rule above because it only ever looked at a *ast.BinaryExpr and only at that node’s two direct operands.

  1. A negated widened constant roots at a unary node. go/types types only the ROOT of a constant operator expression and leaves its operands untyped (updateExprType stops descending once the node it is retyping is itself constant), so []int32{-(1<<31 - 1)} records untyped int on the inner 1<<31 - 1 and int32 only on the negation. There is no typed binary anywhere in the tree to hang the cast on, so nothing narrowed and the element emitted a bare -(2147483648L - 1). strconv’s atoi_test parseInt32 table — {"-2147483647", -(1<<31 - 1), nil} against an int32 struct field — is exactly this (CS1503). widenedConstExprCastType now accepts a unary root too and convUnaryExpr applies the cast at its own emission, mirroring convBinaryExpr: (int32)(-(2147483648L - 1)). ^ takes the same treatment ((int32)(~(2147483648L - 1))) and an int target narrows to nint; the non-constant unary operators (&x, <-ch, !b) are excluded by the existing constant-value and integer-kind guards.
  2. The fold need not be a DIRECT operand. The operator form is emitted over the operand renderings, so a subtree that does not fold itself still renders long when one of its operands does: in []int32{(1<<31 - 1) - 1} the root’s operands are (1<<31 - 1) (value in range, unfolded) and 1, and only the grandchild shift folds — likewise []int32{1<<40>>20 - 1}. operandRendersWidenedFold now descends the whole constant subtree instead of testing one level. Descent cannot over-report: because go/types leaves the operands of a constant operator expression untyped, no interior node ever carries a narrowing cast of its own, so exactly one cast is emitted at the root — the guard test pins (int32)((2147483648L - 1) - 1), not a doubled form.

The unary root is also taught to the explicit-conversion path (int32(-(1<<31 - 1))), which returns the operand’s own cast rather than doubling it — the same wholeExprIsCastOfType check that path already applied to a binary operand. (Guarded by the ConstSubexprOverflow extension — negated/^/deep-fold elements for int32, int, and an in-range int16 control, plus a typed assignment, an explicit conversion, a struct-field table entry and two call arguments, values vs Go; counter-proven against the pre-fix converter, whose emission fails to compile with ten CS0266/CS1503 on exactly those positions.)

A subexpression past INT64 under a NATIVE-WIDTH unsigned target folds with its own (nuint) cast (2026-07-20). The narrowing above needs the whole value to be int64-exact, and the fold that produces its widened operand originally ran only under a plain uint64 target — a native-width target (uint/uintptr, and any named type over them) was left with its visible error, since nuint has no implicit conversion from ulong and the fold could not name the target. Go’s arbitrary-precision rule makes this shape ordinary in numeric code: math/big’s nat{0, 0, 1 + 1<<(_W-1), _M ^ (1 << (_W - 1))} (int_test.go’s TestQuoStepD6, where Word is a named type over uintptr and _W is 64) has an inner 1 << 63 of 9223372036854775808 — past int64 entirely, so no signed long fold can carry it — while each element’s own value is representable in Word. Left alone, C# computed the element in int32: 1 + (1 << (int)(63)) against a Word element (CS0029), and (nuint)_M ^ (1 << (int)(63)) mixing nuint with int (CS0019).

The fold now covers uint64 and both native-width spellings — Go uint renders as nuint, Go uintptr as golib’s distinct uintptr struct — and carries the narrowing itself for the native-width pair, in the same parenthesized form wholeExprIsCastOfType recognizes as the (nint)(…) arm on the signed side:

Go Was emitted Now
[]Word{1 + 1<<63} (Word uintptr) 1 + (1 << (int)(63)) — CS0029 (nuint)(9223372036854775809UL)
[]uintptr{_M ^ (1 << 63)} (uintptr)_M ^ ((1 << (int)(63))) — CS0019 (nuint)(9223372036854775807UL)
[]uint{1 + 1<<63} 1 + (1 << (int)(63)) — CS0029 (nuint)(9223372036854775809UL)
[]uint64{1 + 1<<63} 9223372036854775809UL unchanged

The cast is spelled nuint for both native-width targets rather than naming the target: it is the primitive C# native unsigned type, and it converts implicitly to golib’s uintptr struct and to a [GoType] wrapper over uintptr alike — so one spelling covers uint, uintptr, and named types over either, with no target-name synthesis. The uint64 emission is untouched (it already had an implicit conversion from ulong), so this is zero-churn on the existing corpus — CNR is byte-identical across all 434 behavioral projects. (Guarded by the same ConstSubexprOverflow behavioral test, extended with a named-uintptr Word type plus plain uintptr/uint/uint64 elements of the beyond-int64 shape, values verified vs Go.)

See Named Numeric Types and Constant Contexts for how these interact with native-int and named numeric types. See also example.

Native and Narrow Integer Types

In Go the int and uint types are sized according to the platform build target, i.e., 32-bit or 64-bit. C#’s int/uint are always 32-bit and long/ulong are always 64-bit. As of C# 9.0, native-sized integer types exist that behave exactly like their Go counterparts: nint and nuint. The converter maps Go intnint and Go uintnuint; uintptr also maps to nuint. The fixed-width Go types (int8/16/32/64, uint8/16/32/64, byte, rune) are kept as readable C# aliases of the same name (e.g. global using uint16 = System.UInt16;).

Narrow-integer arithmetic. A subtle semantic gap: Go evaluates arithmetic on a sub-int-width integer (int8/uint8/int16/uint16) at that operand’s own width, with overflow wrappingvar a, b uint8 = 200, 100; a + b is 44 (300 mod 256). C#, however, promotes arithmetic on byte/sbyte/short/ushort to int, so a + b is 300 and is not implicitly assignable back to the narrow type. Where a narrow-arithmetic result is used in a context that requires the narrow type — e.g. passed to a narrow-typed parameter — the converter emits an explicit cast back to that type, which both compiles (the implicit int→narrow conversion is rejected, CS1503) and restores Go’s wrapping:

takeU8((uint8)(a + b));   // Go take(a + b), a/b uint8 → 44 (wraps), not 300
takeU8((uint8)(~a));      // Go take(^a) → 55

The same cast applies in the assignment context — a narrow-arithmetic value assigned to a narrow variable, array/slice element, or struct field (y := a + b; y = y + 1; arr[0] = a + b; bx.b = a + b) — and in the declaration context — a typed-var initializer (var z uint8 = a + b). All emit (uint8)(a + b) for the same two reasons. (A double cast is avoided when another path already narrowed the RHS, e.g. a bitwise op with an untyped constant emits its own (byte)(b | 128).)

The cast is applied only when the value’s Go type already matches the target (parameter / LHS / declared type), so Go accepts it without a conversion, and only for an arithmetic (binary/unary) expression — a bare identifier is already the narrow type. (Guarded by the NarrowArithmeticArg behavioral test, which verifies the wrapped values match Go across all four contexts. Wider integer types — int32/uint32 and up — are not promoted by C# and need no cast.)

A redundant-cast guard on this decision — skip the cast when the converted RHS is already a full narrowing ((byte)(b | 128)) — must distinguish a WHOLE-expression cast from one that only converts the FIRST operand. buf[i] = byte(e/100) + '0' (runtime print.go) emits the RHS (byte)(e / 100) + (rune)'0', which starts with (byte)( but only casts e/100; the binary result is still int (the (rune)'0' promotes it), so the narrowing cast is still required (CS0266). The guard therefore checks that the cast-paren’s matching close is at the very end of the RHS (a parenthesis-balance walk that skips (/) inside char/string literals), not merely that the RHS begins with (byte)(. (Guarded by the NarrowByteArithFirstOperandCast behavioral test — including a wrapping case; cleared 3 runtime CS0266 in print.go’s exponent formatting.)

The per-argument cast path (convExprList, which applies castArgToType — e.g. an append element cast to the slice’s element type) carries the same redundant-cast guard and shares the same whole-expression test. It previously used a bare strings.HasPrefix check, which the narrow-shift result cast above newly exposed: append(s, uint16(v[0])<<8 + uint16(v[1])) (vendor/golang.org/x/text’s BMP-string decoding, crypto/x509) emits (uint16)(…<<8) + (uint16)v[1], whose leading (uint16)( covers only the shifted first operand while the sum still promotes to int — so the prefix test wrongly skipped the element cast that the whole expression still needs. Both sites now call the same balance-walk helper, so a first-operand-only cast is never mistaken for a whole-expression narrowing.

The same narrowing applies to a return of narrow-integer arithmetic. func lowerASCII(c byte) byte { return c + ('a'-'A') } (runtime env_posix) returns byte + int (the untyped char constant promotes to int) → CS0266 against the byte result type. The cast was applied on the assignment and value-spec paths but not the return path; it is now applied in visitReturnStmt when the function’s result type at that position is narrow and the returned expression is binary/unary arithmetic (reusing the same gate — a bare identifier, a call, or an already-whole-expr-narrowed return is untouched, and a non-narrow result type is unaffected). (Guarded by the NarrowByteArithReturn behavioral test — a per-branch return plus a wrapping case; cleared the env_posix.lowerASCII CS0266.)

The same narrowing applies to a narrow-arithmetic comparison operand (2026-07-18). A narrow (int8/uint8/int16/uint16) NON-constant arithmetic/complement result compared directly — with no narrow destination to force the cast — keeps its C# int-promoted value and so compares WRONG: int8(MaxInt8) + 1 != MinInt8 evaluates 128 != -128 (true) where Go wraps at int8 width to -128 != -128 (false). convBinaryExprCore now wraps each comparison operand (== != < <= > >=) that is a narrow non-constant arithmetic expression at its own width — (int8)(v + 1) != MinInt8 — through the same narrowArithmeticCastTypeFor helper (narrowComparisonOperand), gated to NON-constant operands: a Go constant cannot overflow its type, and a wrap cast on a C# compile-time constant expression is CS0221 (the shift-retype path guards identically). Unlike the destination contexts, this had no compile symptom — the code compiled and only the value of the comparison was wrong — so it surfaced only when math’s TestMaxInt/TestMaxUint ran. (Guarded by the NarrowArithmeticArg behavioral test’s comparison cases a+b == 44 / c+d == -56 / e+f != 4464 / ^a > 50, output-compared vs Go.)

A related wide case: a computed constant arithmetic expression assigned to a native-width integer (uintptr/uint/int → C# nuint/nint) whose folded value overflows int32. pattern = 1<<maxBits - 1 (runtime mbitmap, maxBits = 57) is a uintptr constant, but the converter folds the untyped sub-shift 1<<maxBits to a signed C# long literal (144115188075855872L, since it exceeds int32 and the untyped operand is treated as signed), so the whole RHS is long — which has no implicit conversion to the native target (CS0266). A UL/(nuint) suffix would not help (ulongnuint is also an explicit conversion). The converter wraps the whole RHS in the native target’s cast: pattern = (uintptr)(144115188075855872L - 1). This fires only when the constant fits int64 but is out of int32 range — exactly the signed-long fold range. A value that overflows int64 (a large unsigned uintptr like 1<<63 + 1<<62) is deliberately left alone: its sub-shift already mis-emits (a 1<<63 int-shift), so casting it would convert a visible compile error into a silent wrong value — that is a separate defect to fix on its own, not to mask. (Guarded by the NativeIntWideConstAssign behavioral test — uintptr/uint/int targets with int64-range constants, values verified vs Go; cleared the mbitmap CS0266, the last one in runtime.)

A runtime shift count that can reach the operand width uses Go-semantics helpers (2026-07-18). Go zeroes a shift whose count reaches or exceeds the operand’s bit-width — x >> n / x << n with n >= width is 0 (a SIGNED right shift sign-extends: 0 for a non-negative value, -1 for a negative one). C#’s native >>/<< instead mask the count (n & 63 for a 64-bit operand, n & 31 for 32-bit, and sub-int operands promote to int and mask by 31), so a native shift by a runtime count silently yields the wrong value once the count can reach the width — math.FMA’s double-word funnel shifts (u2 >> (64 - n) at n == 0: Go >>64=0, C# >>0=u2) and math.RoundToEven’s >> e (e up to 1024 for a NaN) both corrupted. The converter keeps the native shift ONLY when the count is PROVABLY in [0, width): R1 a constant in range (the majority of shifts — every x >> 5; a constant >= width routes to the guard, which returns 0); R2 a syntactic mask y & M with constant M <= width-1; R3 a modulo y % M with constant M <= width. Everything else — a bare variable count (x >> s, which genuinely can exceed width, as RoundToEven’s e proves, so it cannot be trusted) or an arithmetic count (64 - n, shift - e) — routes through golib’s GoShift.Rsh/Lsh extension methods, x.Rsh(n) / x.Lsh(n) (the naming echoing Go’s math/big.Int.Rsh/Lsh): one guarded shift per operand width returning 0 (or sign-extending) at n >= width. The count is taken as a wide uint64 so a computed count like 64 - n that unsigned-wraps to a huge value is compared at FULL magnitude BEFORE narrowing to the int shift amount — truncating to int first would defeat the guard. A named-[GoType]-wrapper left operand keeps its generated-operator path, as does a compound untyped-const UntypedInt left operand (its own operator<<, the UntypedIntWideShift subject) — but the WRAPPER OPERATOR now carries the guard itself (2026-08-09), because leaving it native left that entire family with the masked answer and no other layer covers it. NumericTypeTemplate’s operator << / operator >> emit value.m_value.Lsh((uint64)shift) / .Rsh(…) in place of the native value.m_value << shift, so a named integer type shifts with Go’s semantics wherever the converter’s own provability analysis cannot reach. What motivated it was not a wrong number but a hang: math/big’s lehmerSimulate reads a2 = B.abs[n-2] >> (_W - h) on Word (type Word uint), and for a normalized operand h == 0, so the count is exactly 64 — Go yields 0, C# yielded the word itself. The corrupted Lehmer cosequences make GCD’s for len(B.abs) > 1 loop stop converging: an INFINITE LOOP inside math/big, reached from crypto/elliptic’s generic CurveParams path, so elliptic.P256().Params().Double(Gx, Gy) never returned. That is crypto/ecdsa’s TestINDCCA/P256/Generic, carried on the board as a 20-minute timeout open between “performance gap or hang”; the fixed path runs in 0.31 s against Go’s 0.66 s, so it was never slowness. It is value-dependent, which is why it hid so long — a garbage a1/a2 that fails Collins’ stopping condition immediately costs only a Euclidean step, so equal-width operand pairs pass and only pairs that make the condition iterate corrupt anything. operator >>> stays native: Go has no unsigned-right-shift operator, so nothing converted ever calls it. Measured footprint: of ~3,556 corpus shifts, ~80% (constant, named-const, and masked/modulo counts) stay native and byte-identical; only the ~20% of unprovable variable/arithmetic counts become .Rsh/.Lsh — the SOUND floor without value-range analysis (a lightweight loop-bound/range recognition could shrink it later). This replaces the narrow-shift result retype for a runtime count ((byte)(cb << (int)(k))cb.Lsh(k), the Lsh(byte) overload doing both the width wrap and the k>=8→0). (Guarded by the GoShiftSemantics behavioral test — 64/32-bit unsigned shifts by runtime counts 0/1/63/64/65/200, signed sign-extension, and R2/R3 masked/modulo counts staying native, output-compared vs Go; cleared math’s TestFMA + TestRoundToEven, whose funnel shifts now emit guarded automatically. Extended 2026-08-09 with the NAMED forms — type word uint / halfword uint32 / signedword int64 shifted by the same runtime counts, including the w >> (W - h) shape lehmerSimulate writes, which is the wrapper-operator route rather than the guard route.)

The signed integer minima sign-fold at the unary level. Go folds -literal into one constant, but the emitter classifies the POSITIVE operand literal alone, and both signed minima’s magnitudes overflow their own type: []int32{-2147483648} (internal/fuzz mutator’s interesting32) saw 2147483648 > MaxInt32 and emitted -(nint)2147483648L, which has no implicit conversion back to an int32 slot (CS0266); the int64 minimum’s operand 9223372036854775808 does not even parse as int64, routing through the unsigned branch to -(nuint)9223372036854775808UL — and C# defines no unary minus on nuint at all (CS0023). convUnaryExpr’s token.SUB handling now mirrors its FLOAT arm: for an INT literal operand it classifies the range on the unary expression’s resolved (sign-folded) constant. The exact int32 minimum in an int32-typed context emits the plain negated literal -2147483648 — C# special-cases the negated decimal int-min as an int constant, by value, so _ digit separators survive (-2_147_483_648 compiles, proven by the guard) — and the exact int64 minimum emits -9223372036854775808L (the matching long special case), wrapped as ((nint)(-9223372036854775808L)) in a Go-int context where long has no implicit conversion. Decimal source formatting is preserved per the literal-formatting rule; hex/binary re-render as decimal (C# has no signed special case for those forms — -0x80000000 binds as a long-typed expression). Everything else keeps the default path: in-int32 operands never had a problem, and a folded int32-min in a WIDER context (var x int64 = -2147483648, or boxed to any where Go-int must stay nint) keeps the implicitly-convertible -(nint)…L form — the full-stdlib A/B footprint was exactly the one mutator.cs line. (Guarded by the IntMinLiterals behavioral test — int32-min plain and underscored in []int32, int64-min in []int64, the nint-min := form, between-minima and non-minimal negative controls, and min-value comparisons, values vs Go; the pre-fix converter fails it CS0266 ×2 + CS0023 ×2.)

Constant-literal return inside a lambda with an unsigned result (delegate-type inference, CS8917). A Go closure assigned to a local — casePC := func(casi int) uintptr { if pcs == nil { return 0 }; return pcs[casi] } (runtime select.go) — is emitted as var casePC = (nint casi) => { … };, whose delegate type C# must infer from the return-expression types. The literal return 0 is typed int; return pcs[casi] is typed nuint (uintptr). C#’s best-common-type algorithm uses the expression types (not constant convertibility), and int has no common type with nuint/uint/ulong (there is no implicit int→unsigned conversion for a non-constant), so the var assignment fails with CS8917 (“no best type found for the lambda”). The converter casts the literal to the result type so both returns share it: return (uintptr)(0). Gated tightly to avoid churn and new errors: only inside a lambda body (conversionInLambda — a named func’s return 0 to a nuint result compiles as an ordinary constant conversion and needs no cast), only for a bare integer literal (the sole shape that trips the int-vs-unsigned inference gap — byte/uint16 widen to int, and the signed/nint/long kinds share a common type with int, so those never hit CS8917), and only when the result is a basic uint/uint32/uint64/uintptr (a named type over an unsigned kind is left alone — (gclinkptr)(0) would only compile if that type defined an int conversion, so casting it could introduce a new error). Runs after the narrow-arithmetic return cast, with which it is disjoint (that handles binary/unary arithmetic on sub-int types; this handles a bare literal to a wide unsigned type). (Guarded by the ClosureMixedReturnUnsigned behavioral test — uintptr/uint64/uint32/uint mixed-return closures plus a signed control that stays uncast, values verified vs Go; cleared the select.go casePC CS8917.)

A beyond-int32 integer constant takes the width of the type it RESOLVED to, not the untyped default (2026-08-08). A Go integer constant outside the C# int32 range cannot be written bare in a native-width slot — long has no implicit conversion to nint — so the converter wrapped every one of them in (nint)…L. That is right only when the constant really is a Go int. When it resolved to int64 the cast is the wrong type: int64 is C# long, so the digits alone denote it exactly, and the (nint) both truncates on a 32-bit target and reads nothing like the Go source. The compiler says so — CS8778, “constant value may overflow nint at runtime” — and 607 of the corpus’s 620 such warnings were one table, math/rand’s rngCooked [607]int64:

// before — every element carries the untyped-int DEFAULT type
internal static array<int64> rngCooked = new int64[]{
    -(nint)4181792142133755926L, -(nint)4576982950128230565L, (nint)1395769623340756751L, 

// after — the element type the Go source declares, and the Go source's own digits
internal static array<int64> rngCooked = new int64[]{
    -4181792142133755926L, -4576982950128230565L, 1395769623340756751L, 

The cause is a deliberate go/types behavior that stays invisible until you look for it: updateExprType0 short-circuits with “if x is a constant, the operands were constants” and does not descend into the operands of a constant expression, because in Go they never materialize at runtime. So in [...]int64{-4181792142133755926, 1395769623340756751} the NEGATED element records untyped int while its positive sibling records int64 — purely because one is wrapped in a unary minus. Every element of rngCooked is negative, which is why that whole table lost its element type while positive-only tables elsewhere kept theirs. convBasicLit therefore resolves the literal’s integer type from two routes, exactly as it already does for the float F/D suffix: the type go/types recorded directly, and failing that the contextual type markUntypedConstContexts propagated (which already pushes an integer context through unary +/-/^ and arithmetic operands). An int64 resolution emits the bare …L; everything else keeps nint — it must, because an any slot has to box a Go int as nint so a later x.(int) succeeds — but as unchecked((nint)…L), which is what makes a beyond-int32 constant conversion legal without the warning (nint is 64-bit on every platform go2cs targets, so the value is exact). The same unchecked covers the other two emitters of a native-width constant: convBinaryExpr’s constant FOLD (unchecked((nint)(4611686018427387903L))bufio’s maxInt/2) and csNintLiteral’s array LENGTH (unchecked((nint)140737488355327) — runtime’s (*[maxAlloc/2 - 1]byte) casts). The fold keeps its parenthesized (T)(…) body: wholeExprIsCastOfType, the redundancy guard 17 call sites share, now peels an unchecked( wrapper first, so enclosing paths still recognize the cast and do not re-wrap it into (nint)(unchecked((nint)(…))). (Guarded by nativeIntConstWidth_test.go — the negated int64 element and its positive sibling, the int var and the any slot both taking unchecked, the fold NOT double-wrapped, an in-range constant untouched, plus a unit test pinning the recognizer’s peel. Corpus effect: CS8778 620 → 0.)

One sticking point: not all C# indexing constructs accept a nint. Explicit indexers support nint, but implicit index support (the Index/Range syntax) currently only works with int, so range-operation indices are cast to int where needed. (The earlier strategy of compiling to long/ulong, or of custom @int/@uint structs selected by a TARGET32BIT directive, has been superseded by nint/nuint.)

Named Numeric Types and Constant Contexts

General untyped constant representation is covered in Constant Values. This section records the places where constants and operators become difficult because a numeric context is already known: named numeric wrappers, native-width targets, typed-element contexts such as append, shift and bit-mask operands, and the casts needed to keep C# overload resolution aligned with Go.

This area is where Go’s flexible numeric model meets C#’s stricter one, and it has a few moving parts worth calling out.

Untyped constants. As noted under Constant Values, an untyped Go constant becomes a golib UntypedInt/UntypedFloat/UntypedComplex. These wrappers define implicit conversions to and from every numeric type so the value can slot into whatever context uses it, just like an untyped Go constant. The trade-off is that mixing an UntypedInt directly into heavily-typed arithmetic (e.g. someUint64 * untypedConst) can become ambiguous to C#’s overload resolution, since the wrapper is convertible in either direction. A function-local untyped constant whose every use resolves to one concrete basic type sidesteps the wrapper entirely — it is declared AT that type with the per-use casts dropped (see Constant Values, function-local untyped constants tighten); the wrapper-cast machinery below applies to the remaining wrapper-emitted constants (package-level, mixed-context, and folding-participating locals).

One resolved instance: an argument to the min/max builtins that is a named untyped constant renders as its UntypedInt static, which golib’s min<T>(T, params ReadOnlySpan<T>) overloads reject (CS1503 — params-span element binding does not apply the user-defined implicit conversion): runtime min(n, maxObletBytes) (mgcmark.go, uintptr sibling) and min(debug.profstackdepth, maxProfStackDepth) (runtime1.go, int32). The converter casts such an argument to the call’s Go-resolved result type — min(n, (uintptr)(maxObletBytes)) — and, once one argument is cast, every constant-valued sibling too (min(big, limit, 500)…, (uintptr)(limit), (uintptr)(500)) — a bare literal is a C# int and would break T inference against the cast type). Typed arguments and literal-only calls are unchanged. (Guarded by the MinMaxBuiltin extension — untyped consts typed by uintptr/int32 siblings plus the mixed literal case, values vs Go.)

Named numeric types. A Go type definition over a numeric base — type Celsius float64, type level int, type Flags uint — is emitted as a partial struct carrying a num: [GoType] attribute, and the TypeGenerator source generator fills in the body:

[GoType("num:nint")]  partial struct level;   // type level int
[GoType("num:nuint")] partial struct Flags;   // type Flags uint
[GoType("num:float64")] partial struct Celsius; // type Celsius float64

The generated struct wraps the underlying value and implements the comparison and arithmetic operators plus implicit conversions to/from the underlying type, so the named type is a distinct C# type that still behaves like its base.

Increment / decrement. Go allows c++ / c-- on a named integer (e.g. a for c := chunkIdx(0); …; c++ loop counter). The generator therefore emits operator ++/operator -- returning the named typeoperator ++(T value) => (T)(value.m_value + (U)1) (U the underlying). Without a dedicated operator, C# c++ falls back to the implicit conversion to the underlying and re-assigns the (promoted) result, which for a native-int-backed named type (num:nuint/num:nint) promotes to ulong/long and then cannot implicitly convert back to the named type (CS0266). The dedicated operators keep the result in the named type. (Guarded by the NamedNumericIncDec behavioral test — ++/-- on uint- and int-backed named types in loop counters; runtime uses this for chunkIdx/arenaIdx/statDep loop counters, ~7 CS0266.)

Unsigned underlying types and unary minus. Go permits unary minus on an unsigned value (it wraps: -x == 0 - x). C# does not allow the unary - operator on unsigned operands. So the generator’s IsUnsignedType check omits the unary negation operator for unsigned underlying types (uint8/16/32/64, byte, uintptr, and the native nuint/uint). Go’s unary minus on such a value is instead lowered by the converter to the equivalent subtraction-from-zero form:

var b Flags = 2
_ = -b            // Go: unsigned unary minus (wraps)
Flags b = 2;
_ = ((Flags)0 - b);   // C#: lowered to (T)0 - x

This keeps the generated numeric struct compilable (a (T)(-value.m_value) body over nuint is a CS0023 error) while preserving Go’s wrap-around semantics. The same (T)0 - x lowering is used for unsigned unary minus on built-in unsigned values.

Converting to a named numeric type. The generated struct’s implicit conversions are only between the named type and its exact underlying basic (traceArg ↔ uint64, arenaIdx ↔ nuint). So a Go conversion traceArg(procs) where procs is int32, or arenaIdx(1 << b) where the shift is int, has no matching operator — a plain (traceArg)procs is CS0030. The converter coerces the argument through the underlying type first, which is exactly Go’s numeric-conversion semantics:

var procs int32 = 5
a := traceArg(procs)   // type traceArg uint64
b := arenaIdx(1 << 4)  // type arenaIdx uint
int32 procs = 5;
var a = ((traceArg)(uint64)procs);   // through the underlying uint64
var b = ((arenaIdx)(nuint)(1 << 4)); // through the underlying nuint

When the argument is already the underlying basic (traceArg(u) with u uint64), the existing single cast already binds, so no extra cast is inserted (no churn). (Guarded by the NamedNumericConversion behavioral test; runtime exercises this pervasively for traceArg, arenaIdx, traceTime, the abi offset types, etc.)

Converting from a named numeric type. The mirror direction has the same root: because the wrapper only converts between the named type and its exact underlying, a Go conversion from a named numeric to a different basic numericuint64(nameOff) where type NameOff int32, or int(idx) where type idx uint — has no matching operator ((ulong)NameOff / (nint)idx is CS0030). The converter routes it through the named type’s underlying basic first — the named→underlying [GoType] operator followed by an ordinary numeric C# cast:

var s NameOff = 7  // type NameOff int32
e := uint64(s)     // NameOff -> uint64
var i idx = 9      // type idx uint
f := int(i)        // idx -> int
NameOff s = 7;
var e = ((uint64)(int32)s);  // through the underlying int32
idx i = 9;
nint f = ((nint)(nuint)i);   // through the underlying nuint

When the target basic is the named type’s exact underlying (int32(s) for NameOff), the single operator already binds, so no extra cast is inserted (no churn). (Same NamedNumericConversion behavioral test; runtime hits this on the abi offset types NameOff/TypeOff/TextOffuint64/uintptr, taggedPointer/traceTimeint64, etc.)

Cast parenthesization (visual fidelity). A Go conversion T(x) reads as a function call; the C# cast (T)x is the closest equivalent, so the converter keeps it minimally parenthesized to stay close to the source:

Named-numeric wrappers carry the full INTEGER operator surface. The generated [GoType("num:…")] wrapper defines + - * / % ++ -- returning the wrapper; integer underlyings additionally define ~, the shifts << >> (int count), and the binary bitwise & | ^ — all returning the WRAPPER type, exactly Go’s typing (Word >> ŝ IS a Word). Without them C# resolved compound expressions through the implicit-to-underlying conversion and the whole expression degraded to the raw numeric (math/big’s Word arithmetic, CS0266 ×45). Floats/complex omit the integer-only operators.

Named SLICE types keep the named type when sliced. The generated slice wrapper’s Range indexer and Slice() overloads return the wrapper (nat[a:b] IS a nat — a fresh wrapper sharing the same backing window), so a method call directly on a slice expression binds the named type’s extensions (u[s:].norm() bound the raw slice<Word> instead, math/big CS1929 ×21). The explicit ISlice<T> implementations keep the raw slice type.

A conversion between two named slice types sharing an identical underlying (tar’s sparseElem(s[i*24:]), both []byte) hops through the shared underlying slice — ((sparseElem)(slice<byte>)(…)) — since the wrapper-returning slicing makes the argument the NAMED wrapper and a direct cast would chain two user-defined operators (CS0030). (Guarded by SortArrayType’s Roster(byAge[0:2]).) The same hop covers the map and array underlyings (net/mail’s textproto.MIMEHeader(h), where Header and MIMEHeader are both written over map[string][]string).

…but NOT when one of the two was written directly over the other. Go’s Underlying() resolves through the whole declaration chain, so type shuffledFS MapFS — testing/fstest’s own suite, over a MapFS that is itself map[string]*MapFile — passes the shared-underlying test above while being a completely different shape. The wrapper for a non-basic underlying keeps the NAMED base ([GoType("global::go.testing.fstest_package.MapFS")], see visitIdent/visitTypeSpec), so shuffledFS declares exactly ONE conversion operator and it targets MapFS. Hopping through the raw map therefore creates the two-operator chain the hop exists to prevent — shuffledFSMapFSmap, CS0030 — where the plain cast Go actually wrote binds in one step:

f, err := MapFS(fsys).Open(name)          // fsys is shuffledFS
var (f, err) = ((global::go.testing.fstest_package.MapFS)fsys).Open(name);

The written right-hand side is recovered from packageTypeSpecRHS (writtenUnderlyingOperations.go), the same per-package pre-pass the named-numeric hop already consults for its own version of this exception — with one difference: the numeric exception is gated to a CROSS-package base, because a same-package numeric chain resolves to the basic underlying ([GoType("num:uintptr")], no named-base operator to bind). A composite underlying keeps the named base either way, so the composite exception needs no package gate. Both directions are covered (the arg written over the target, and the target written over the arg), and all three composite arms — map, slice and array. Unrecorded or cross-package declarations miss the lookup and keep the pre-existing route.

Measured on testing/fstest, whose whole 7-verdict suite sat behind this one CS0030: it now runs at 6 of 7, the residual being TestShuffledFS’s runtime assertion that the returned *shuffledFile satisfies fs.ReadDirFile — the pointer-adapter identity class, unrelated. (Guarded by the DefinedOverNamedComposite behavioral test: a child package owning the named map/slice/array, defined types over each in both directions plus a same-package one, and the net/mail two-raw-map control in the same program so the hop is proven to still fire.)

A defined type over a named COMPOSITE gets an inherited wrapper that does not expose the golib sequence surface, so len(x) directly on one is CS0315 against builtin.len<TSeq>. That is a separate, pre-existing gap — no corpus site asks for it, and the guard above deliberately measures through the base type instead.

(Guarded by NamedNumericConversion, NamedNumericShiftConv, NamedTypeBitwiseConst, IotaEnum, FuncTypeParam, and CrossPkgUser; the string-target exception is guarded by StringConvPostfix and UnsafeOperations; verified by the full behavioral suite — output comparisons confirm the precedence is unchanged.)

Generated conversion operators between named numerics of different assemblies. The two paragraphs above are the converter’s inline casts. Separately, when the converter sees a conversion between two named numeric types it records a [assembly: GoImplicitConv<…>] and the ImplicitConvGenerator emits a user-defined implicit operator for it. The emitted body constructs one named type from the other’s underlying value: new Target((ValueType)src.Value).

ValueType is a CAST TARGET, and it names the constructed type’s BACKING PRIMITIVE (corrected 2026-08-08). The template applies it to src.Value and feeds the result to the constructed type’s constructor, and that constructor takes the primitive — so ValueType must be uint32, nint, int64, not the wrapper. It named the constructed type itself until this was rooted, making the body a round-trip through that type’s own conversion operators — new WaitStatus((WaitStatus)src.Value) — which compiles only while a standard EXPLICIT conversion exists between the two primitives, because a user-defined conversion admits just one standard conversion on its input. syscall’s unix flavors are where it finally bit: WaitStatus is backed by uint32 and Signal by int (nint), and uint32nint is not a standard IMPLICIT conversion (a 32-bit unsigned value does not fit a 32-bit native int), so its reverse is not a standard explicit one, the operator is not applicable, and the cast is CS0030. Windows declares WaitStatus a struct, so the pair is never registered there and no corpus build reached it. All 49 of the corpus’s ValueType records carried the constructed type’s own name, so the form was never right — only never yet fatal, because the two compensating generator overrides below cover most of the gap. One consumer had to move with it: the uintptr hop read ValueType as the type to CONSTRUCT (new {valueType}(…)) and now constructs the LH type and casts to ValueType, exactly like the default body. (Guarded by implicitConvValueType_test.go — an end-to-end conversion of two named numerics with different underlyings, plus a unit sweep over every basic kind a named numeric can carry.)

When both named types live in the same assembly the default body is fine (e.g. runtime’s muintptr ↔ Δhex), but when the operator must construct a foreign named numeric — one declared in another C# assembly — two problems appear that only manifest cross-assembly:

So for a foreign constructed type the generator emits, fully-qualified and hosted in the local type:

// runtime, dur↔hex style: foreign abi.NameOff constructed from local Δhex
partial struct Δhex {
    public static implicit operator global::go.@internal.abi_package.NameOff(global::go.runtime_package.Δhex src)
        => new global::go.@internal.abi_package.NameOff((int)src.Value); // through the underlying int32
}

The override fires only when the new-constructed side (the LH type: the source when the conversion is Inverted, else the target) is foreign; same-assembly operators are emitted byte-identically as before (no churn). Because the trigger is inherently cross-assembly, the behavioral-test harness (single-assembly, and unable to import a foreign named numeric — internal/* types are un-importable from a test module and the baseline stubs expose none) cannot host it; the guard is the core/runtime build, where NameOff/TypeOff/TextOffΔhex naturally occur (this fix cleared 3×CS0030 + 3×CS1729 there).

A same-assembly pair also needs the through-underlying routing when the two named numerics have incompatible underlyings — internal/trace’s public type Time int64 ↔ unexported type timestamp uint64, converted both ways (Time(ev.Ts) / timestamp(ts)). The default new Time((ΔTime)src.Value) casts src.Value (a ulong, since timestamp is uint64-backed) straight to the wrapper, which routes through the wrapper’s long-based user conversion — but ulonglong is not an implicit C# conversion, so the cast is CS0030. (This is the mixed-accessibility case: Time is exported and timestamp is not, so the operator is already relocated into the less-accessible timestamp struct — orthogonal to the underlying.) The generator now, for a local numeric pair, casts through the constructed type’s underlying C# keyword when the source underlying does not implicitly convert to it: new Time((long)src.Value), new timestamp((ulong)src.Value). The source/constructed underlyings are read from each side’s [GoType("num:X")] tag (a sibling generator cannot see the generated Value property), and the implicit-convertibility test is the fixed C# numeric-conversion table over the fixed-width integer/float basics. Crucially this fires only on pairs the default cast could not compile (the default (Wrapper)src.Value succeeds iff that same source→underlying conversion is implicit), so every already-compiling conversion stays byte-identical — the full behavioral suite’s goldens are unchanged. uintptr-backed pairs keep the existing nuint-hop override; int/uint native-width wrappers are deliberately left to the default (their classification is version-sensitive and the failing corpus cases are fixed-width). (Guarded by the NamedIntSignednessConv behavioral test — a public int64 ↔ unexported uint64 named pair converted both ways, including a ^uint64(0)int64 case whose -1 result verifies the cast preserves the bit pattern exactly, output-compared vs Go; internal/trace’s timestampTime inverse operator relies on it.)

A cross-assembly mixed-accessibility pair has no legal form at all, and is skipped. The relocation above is the only remedy for a mixed pair — a C# user-defined conversion operator is necessarily public and must be declared in one of its two operand types — and a foreign type cannot host anything. Hosting in the local, more accessible side then exposes a type less accessible than the operator: CS0056 when the foreign side is the return type, CS0057 when it is the parameter, so neither direction is expressible. The shape is reachable only under the -tests white-box model, where a package’s own _test.go declares an EXPORTED defined type over an UNEXPORTED production one — time’s export_test.go has type RuleKind int beside zoneinfo.go’s type ruleKind int, which become public RuleKind in the test assembly and internal ruleKind in the referenced production assembly. Nothing is lost by skipping: the converter renders such a conversion site as an explicit through-underlying cast ((RuleKind)(nint)r.kind), which needs no operator at all. The local side’s accessibility comes from the GO export rule, as in the relocation above (at analysis time the [GoType] partials are modifier-less); the FOREIGN side is read from metadata, where it is already final.

The recorded GoImplicitConv must also be able to name the foreign type. The recorded type name carries the foreign package’s import qualifier — the DOT form driver.IsolationLevel for an unrenamed type, or a global-using alias (CrossPkgLibꓸGrade) for a Δ-renamed one — but the attribute sits in package_info.cs at file scope and the generated operator lands in a .g.cs, neither of which carries the body files’ import usings. A Δ-renamed foreign numeric resolves through its own global using, but the dot form needs a resolving using driver = go.database.sql.driver_package; in package_info.cs’s ImportedTypeAliases block. The STRUCT-conversion branch of checkForImplicitConversion already drives that using by calling recordConversionPackageUsing(argType)/(funcType), but the aliased-NUMERIC branch omitted it — so a cross-package named-numeric conversion (database/sql’s driver.IsolationLevel(opts.Isolation), where sql.IsolationLevel and driver.IsolationLevel are distinct named ints) left driver unresolved in both the attribute and the generated operator (CS0246). The numeric branch now records the same package usings. (Guarded by an extension to the CrossPkgUser cross-assembly test — a local float64-based named numeric converted to the unrenamed CrossPkgLib.Celsius, which renders in dot form and so needs the registered using; a Δ-renamed target like CrossPkgLib.Grade would have resolved via its alias and would not have caught the gap.)

The same underlying routing applies when an untyped-constant shift is re-typed to a named numeric. An untyped shift 1 << k is re-typed to the type it assumes from context (so it can combine with typed operands); when that resolved type is a named numeric, the re-type must go through the underlying — (arenaIdx)((nuint)1 << k), not a bare (arenaIdx)(1 << k) (CS0030). The shift’s width is likewise decided by the underlying (a nuint/uint64-backed named type shifts the left operand in that width to avoid the int-overflow seen for 1 << 63). Non-named shifts are unchanged. (Guarded by the NamedNumericShiftConv behavioral test — wide uint/uint64-backed and narrow uint8-backed named types; runtime hits this on arenaIdx(1 << arenaBits).)

The unsigned named-numeric path above gets a width-cast operand, but a signed constant operator expression whose target is a plain builtin int64 has no such cast, so C# would compute it in int32 and overflow at compile time in checked mode (CS0220): int64(1<<63 - 1), var d int64 = 1<<40 + 7, or 12345 * 1000000000 + 54321 passed to an int64 parameter. Go evaluates each as a constant in its int64 type. For a signed constant binary/shift expression whose folded value is outside the C# int32 range, the converter emits the folded 64-bit literal (9223372036854775807L, 1099511627783L, 12345000054321L) instead of the operator form — correct, and self-contained. In-range constants are unchanged (they keep the readable 1 << k form). (Guarded by the UntypedConstArithmetic behavioral test; runtime hits this in mgcmark/netpoll/runtime1.)

A signed fold whose resolved type is Go int (C# nint) additionally carries its own cast (2026-07-17): C# has no implicit longnint conversion, so the bare L fold failed loudly at every non-assignment use — strings’ SplitN test table puts math.MaxInt / 4 in an n int struct field, and the composite-literal element emitted 2305843009213693951L against the nint field (CS1503; the Phase-4 blocker-map row B7a, one site each in strings and bytes). The fold now emits (nint)(2305843009213693951L) — the parenthesized cast form the assignment path’s nativeIntConstCastType already recognizes (wholeExprIsCastOfType), so assignments that previously received the whole-RHS wrap render byte-identically (the cast simply moves into the fold; NativeIntWideConstAssign’s n = (nint)(144115188075855772L) is unchanged). The value always fits — nint is 64-bit on all supported platforms — and the conversion is a runtime unchecked narrowing, never a C# constant expression, so no checked-context overflow arises. An untyped-int subtree keeps the bare L form (its enclosing context supplies the conversion), as does an int64 target (long is already exact). (Guarded by the NativeIntWideConstElement behavioral test — composite-literal elements, call arguments, and a var initializer, values verified vs Go.)

The in-range widened sibling (2026-07-17; sort’s test-suite conversion): a typed-int constant operator expression whose value fits int32 but whose emitted arithmetic an operand fold has widened to longmaxswap: 1<<31 - 1 (sort_test countOps): the whole value (2147483647) is in range, so no whole-expression fold applies, but the untyped inner shift folds to a bare 2147483648L, making the rendering 2147483648L - 1 — a C# long with no implicit conversion to the nint composite field (CS1503; assignments were equally unprotected, since nativeIntConstCastType also requires the whole value out of int32 range). convBinaryExpr now wraps such an expression in the same parenthesized cast at its own emission: (nint)(2147483648L - 1), position-independent. The trigger is shape-restricted (operandRendersWidenedFold): an operand must be an operator subtree whose overflowingConstLiteral fold is non-empty — a named untyped-const reference of the same value renders as its Untyped* wrapper, which narrows itself at the use site (maxInt - maxInt stays unwrapped; wrapping it would churn green emissions). (Guarded by the NativeIntWideConstElement extension — the 1<<31 - 1 element, call argument, and assignment, plus the wrapper-operand control, values vs Go.)

FLOAT literals in INTEGER contexts render their integer form (2026-07-17; sort’s test-suite conversion). A float literal directly typed integer by go/types has always folded (math.Inf(1.0)1 — the convBasicLit integer-form rule), but inside a constant operator expression the literal stays untyped float (go/types resolves the context on the outermost node only): search_test’s tests table writes {"descending 7", 1e9, …, 1e9 - 7} against n, i int fields, and the element rendered 1e9D - 7 — a C# double against the nint field (CS1503). Integer contexts now propagate through markUntypedConstContexts exactly like float/complex ones, and a float literal whose propagated context is integer emits its exact integer form: 1000000000 - 7 — the arithmetic stays exact C# int, implicitly convertible everywhere. Two soundness gates: a non-integral literal (1.5) keeps its loud D form (constant.ToInt exactness), and division does not propagate an integer context — Go evaluates an untyped-float constant / in exact rational arithmetic, so a nested quotient may be transiently non-integral (3.0 / 2 * 2 = 3) where folded operands would int-divide (3/2*2 = 2), a silently wrong value; those trees keep the loud double rendering. (Guarded by the NativeIntWideConstElement extension — 1e9 - 7 and 5e8 * 2 elements and a 2e9 - 8 call argument, values vs Go.)

UNSIGNED constant expressions fold under a much narrower trigger (2026-07-03): every other unsigned shape already has a working mechanism — a typed unsigned shift gets the width-cast operand ((uint64)1 << 40), an int64-range untyped subtree is folded by the signed arm when recursion reaches it ((281474976710655L) + arenaBaseOffset in runtime mranges), and a named-const reference renders via its Untyped* wrapper ((uintptr)m5 ^ 4 in runtime hash64). The one unfixable shape is an untyped constant operator subtree (a BinaryExpr) whose value exceeds int64 entirely: 1<<63 nested inside (1 << 63) - 1 — go/types lands the uint64 conversion on the outermost constant node, so the inner shift stays untyped, no width cast reaches it, and C# computes it in int32. int64((1 << 63) - 1 - (1<<63)%uint64(n)) (math/rand Int63n, CS0220) emits as (int64)(9223372036854775807UL - (((uint64)1 << (int)(63))) % (uint64)n): the constant subtree folds to UL, the standalone typed shift keeps its readable width-cast form. Gated to plain-uint64 underlying targets (constExprHasBeyondInt64UntypedOperatorSubexpr) — a native-width uintptr target would need a further cast the fold cannot safely synthesize, so that pre-existing caveat keeps its visible error. A first broader cut (any untyped subtree beyond int32, any unsigned target) regressed runtime’s hash64/mranges by stealing exactly those already-working shapes — the narrow trigger is load-bearing. (Guarded by the UntypedConstArithmetic extension — the Int63n shape, value-compared vs Go.)

uintptr was missing from the width-cast retype the paragraph above relies on (2026-08-09). Everything there is conditioned on “a typed unsigned shift gets the width-cast operand ((uint64)1 << 40)”, which is emitted by the shift retype in convBinaryExpr when isWideShiftType says the target does not promote to int. That predicate listed uint32/uint64/int64/nuint — and uintptr is the one wide unsigned type that does NOT render as a C# primitive: Go’s uint becomes nuint, but Go’s uintptr becomes golib’s uintptr STRUCT. So it fell to the narrow arm, which casts the result — precisely the thing the function’s own comment says does not help, because the shift has already happened in int32. 1 << (4 * goarch.PtrSize) emitted (uintptr)(1 << (int)(32)), C# masked the count to five bits, and the value was 1. golib’s uintptr carries a native nuint and declares operator <<(uintptr, int) over it, so a cast OPERAND (((uintptr)1 << (int)(32))) shifts at 64 bits exactly as nuint does; uintptr simply joins the list. Whole-corpus A/B: eight files, one mechanical family, six of them already-correct sub-int32 values reshaped (16 << 10, 512 << 20, 1 << 16, 1 << 20) and two live wrong answersruntime/internal/math’s MulUintptr overflow fast path, whose guard read 1 instead of 2³² so every uintptr below MaxUint32 “overflowed”, and runtime/mpagealloc_64bit.go’s 1 << heapAddrBits, which computed 2¹⁶ where Go computes 2⁴⁸. The neighbouring 1<<(UintptrSize/2) - 1 was always right, which is what hid this: there the shift is an INNER node still typed untyped int, so the signed fold above takes it whole. CNR is byte-identical across all 576 behavioral packages — no behavioral project had the shape until now. (Guarded by the extended LargeUintptrConst behavioral test, already the MaxUintptr pattern’s home: a context-typed 1 << (4 * ptrSize), a literal-count 1 << 40, and the composite-literal table row beside its always-correct - 1 sibling, values vs go run; and by runtime/internal/math’s banked suite.)

FLOAT contexts need the same fold, and there the damage is silent rather than a compile error (2026-07-17). C# masks a shift count to the left operand’s width (5 bits for int), so an integer-literal constant in a float context — where no arm above applies, because the constant’s type is not an integer — evaluates in int32 and quietly yields the wrong number: var hf float64 = 1 << 63 emitted (1 << (int)(63)), i.e. 63 & 31 = 31 → int.MinValue, and hf / (1 << 60) divided by 2^28 (60 & 31 = 28) instead of 2^60, printing 34359738368 where Go prints 8. Go evaluates the constant in exact arithmetic and converts the result to the float type, so the converter emits the Go-evaluated value as a float literal — float64 hf = 9223372036854775808D, hf / (1152921504606846976D), float32 sf = 1099511627776F — which also carries the values 1<<63 puts beyond int64, where no L/UL fold could reach. Two gates keep the readable operator form everywhere it is already correct: the operands must be all integer literals (that is what makes C# evaluate in int32 — a float-literal operand like 1e18 * 10.0 already computes in double, and a named-const operand renders via its Untyped* wrapper), and the value must be outside int32 (1 << 10 computes identically in C# and is left alone). Unlike the int64 case, an inner shift is not rescued by recursion: Go promotes the operands of 1<<40 * 1.5 to a common kind, so the shift is recorded untyped float — invisible to the signed arm’s integer test — and folds from its propagated context instead (see markUntypedConstContexts under Constant Values); left bare it masks to 256 and silently yields 0.375. The full-stdlib A/B footprint was exactly six lines, every one a live wrong-value bug: math’s normalize (x * (1<<52) off by 2^32), cbrt (2^54), ldexp’s denormal factor (1.0/(1<<53)), pow’s 1<<53/1<<63 branch guards, and both math/rand Float64s — v1 divided by int.MinValue and so returned negative numbers, v2 divided by 2^21 instead of 2^53. (floatContextConstLiteral, convBinaryExpr.go; guarded by the UntypedConstArithmetic extension — the 1<<63/1<<60 float64 and 1<<40 float32 folds, the untyped float nested shift, plus in-range and float-literal controls that must keep their operator form, values vs Go.)

The same fold covers a complex128 context (2026-07-18). complex128 is float64-backed (System.Numerics.Complex), so an all-integer-literal shift whose result type is complex128 — a slice/array element like []complex128{1 << 35, 1 << 240} (math/cmplx’s hugeIn test inputs) — carries the identical int32-masking hazard: 1 << 35 emitted (1 << (int)(35)), which C# masks to 35 & 31 = 3 → 8 instead of 2^35, silently corrupting the complex value’s real part (its imaginary part is 0, so it is not int-literal arithmetic). floatContextConstLiteral takes the constant’s real part and folds it to a D-suffixed literal — 34359738368D, and the 73-digit exact form of 1<<240 — which C# parses to the same float64 the Go constant rounds to (a power of two lands exactly; a mixed value like 1234567891234567 << 40 round-trips to the nearest double, matching Go). complex64 is deliberately excluded: its float32 real part would overflow to a C# compile error for the beyond-float32 magnitudes this fold targets, and such constants do not arise. This cleared math/cmplx’s TestTanHuge, whose huge Tan inputs were being reduced to tiny masked values (8, 65536, 4096) — Tan then computed correctly on the wrong arguments. (Guarded by the ComplexConstContext behavioral test — 1<<35/1<<240/-1<<120/1234567891234567<<40 complex128 real parts, values vs Go.)

The same coercion is needed where the converter itself inserts a C# (int) cast on a named-numeric value — a slice bound (summary[sc+1:ec] with sc/ec of type chunkIdx), a shift count (1 << (d % 64) with d of type statDep), or the length of an unsafe.Pointer-to-array slice ((*[N]T)(ptr)[:n]new slice<T>(new ReadOnlySpan<T>(ptr, (int)n)), since the ReadOnlySpan<T> constructor takes a C# int — see Slicing a pointer-to-array). A bare (int)(sc + 1) is CS0030 for the same reason, so the converter emits (int)(nuint)(sc + 1) / (int)(nint)(d % 64) — through the named type’s underlying basic; a plain nint/nuint length is narrowed (int)(n). Plain basic operands keep the bare (int)(x) form. (Guarded by the NamedNumericIntCast behavioral test; the Span length by StdLibInternalAbi.)

Defined types over a struct — forwarded fields. A Go type definition over a structtype winlibcall libcall — makes the underlying struct’s fields accessible on the named type (w.fn), without promoting its methods. The named type is emitted as [GoType("libcall")] partial struct winlibcall; and the TypeGenerator wraps the underlying value (private libcall m_value;). For the underlying’s fields to be reachable, the generator forwards each as a ref-returning property over m_value:

private libcall m_value;                 // NOT readonly — see below
[UnscopedRef] public ref nuint fn => ref m_value.fn;
[UnscopedRef] public ref nuint n  => ref m_value.n;
// … args, r1, r2, err

The underlying struct is resolved with GetStructDeclaration (same package, or a source-referenced package), and its members come from GetStructMembers. Crucially m_value is mutable (not the wrapper’s usual readonly), so a write through a pointer — c.Value.fn = fn, where c is a ж<winlibcall> and c.Value is ref winlibcall — reaches the real storage and persists. (The readonly→mutable choice is decoupled from the nullable-m_value form that only the lazily-allocated array backing needs.) Forwarding is skipped for a non-struct underlying (a named type over an interface or another named type) and for an underlying that contributes no fields, so those wrappers are unchanged. Composite-literal construction of such a type (winlibcall{fn: x}) is a separate, not-yet-handled case (the runtime accesses these only by field). (Guarded by the NamedTypeOverStruct behavioral test — write-through and read-back of forwarded fields through a pointer; runtime hits this on winlibcall over libcall, syscall_windows.go.)

The forwarded member must be a VARIABLE, and the underlying may be METADATA-ONLY (2026-07-31). Two independent defects in the paragraph above, both surfaced by index/suffixarray’s suffixarray_test.gotype index Index, where Index has an ints-typed field sa with len/get methods — and both fixed generally:

  1. A get/set property is not a variable. In Go the selection is the underlying field, so x.sa.len() binds a receiver the converter emits this ref (every value-receiver method is a ref extension) and &x.sa / x.sa.Push(…) take its address. A get/set property yields a value, so all of those were CS0206 (“a non ref-returning property or indexer may not be used as an out or ref value”). The forward is now a ref-returning property, which is a strict superset — w.fn = v still assigns (through the ref), and the variable-requiring uses now bind. [UnscopedRef] is what makes it legal at all: a struct member returning a ref to instance state is CS8170 by default (the receiver could be a temporary), and the attribute states the ref’s lifetime is the receiver’s — exactly Go’s guarantee, since the selection aliases the wrapper’s own storage. C#’s ref-safety rules then reject at the call site precisely the cases Go also rejects (addressing a non-variable). Note the neighbouring array-view case below keeps its ensure-then-share-copy shape: its accessor must materialize a lazily-allocated backing first, which is a different problem than aliasing an existing field.

  2. A metadata-only underlying resolved to nothing. GetStructDeclaration can only see a struct whose SOURCE is in this compilation or in a CompilationReference; a real MSBuild build hands a <ProjectReference> to the compiler as compiled metadata, so a defined type over a struct in another package forwarded no members at all and every selection on it was CS1061. FindUnderlyingStructSymbol now resolves the [GoType("…")] definition to its INamedTypeSymbol when the syntax walk misses — trying the name as written (global::go.index.suffixarray_package.Index, the fully-rooted form the -tests white-box bridge emits) and then go.-rooted (time_package.Duration, the package-alias-qualified form ordinary cross-package emission uses, is not a CLR name) — and GetForeignStructMembers enumerates it. Membership mirrors StructTypeTemplate’s metadata field scan: instance FIELDS plus the ref-returning, non-indexer PROPERTIES a referenced assembly’s generated wrapper exposes for its embedded and promoted members. Visibility is decided by Compilation.IsSymbolAccessibleWithin rather than a public-only test, which is Go’s own rule projected into C#: an exported field is public and always forwards, while an unexported one is internal and forwards only where C# can reach it — i.e. the friend (InternalsVisibleTo) test assembly, which is precisely the same-Go-package case where Go permits the selection. Ordinary cross-package wrappers over foreign structs whose fields are unexported (type timeTime time.Time) therefore forward nothing, exactly as Go allows nothing.

Both fixes were needed for one package: with only (2), the CS1061 wall collapsed to the board’s originally-reported CS0206 at two sites — a worked example of charter §9’s root-cause layering (the first diagnostic moved rather than cleared). (Guarded by the DefinedTypeOverForeignStruct behavioral test — a ptlike sub-library supplies Outer{Name string; In Inner}, the parent declares type alias ptlike.Outer and reads a forwarded field, writes one, calls Inner’s value- and pointer-receiver methods through the forwarded field with the mutations observed afterwards, writes a nested element, converts back to the underlying, and reads the zero value — output-compared vs go run. It is the cross-assembly sibling of NamedTypeOverStruct, which covers the same-package case, and of DefinedTypeOverPkgType, which covers a cross-package defined type reached only by conversion, never by field.)

Defined types over an array-backed defined type — the IArray view. A second-level definition — type pallocBits pageBits, where type pageBits [8]uint64 is itself an array-backed [GoType] wrapper — is len()‘d and indexed directly in Go (runtime mpallocbits.go), which requires IArray on the outer wrapper (golib len(IArray); CS1503 otherwise, and the named-over-array indexing sites in mgcscavenge/proc/traceback fail the same way). The generator detects this in the bare-name branch — the resolved underlying struct contributes no declared members but its own [GoType] definition is an array form ([N]elem) — and implements IArray<elem> on the wrapper as a view (IArrayViewTypeTemplate). Every member delegates through a private view accessor that first touches m_value.Value on the mutable field — materializing the underlying’s lazily-allocated backing in the wrapper’s own storage — and then returns a value copy sharing that heap T[], so element refs land in the real storage. (Going through the plain copying Value property instead silently dropped writes on a zero-valued wrapper — the backing allocated on the copy — which is the historical pallocBits lost-writes trap, reproduced and pinned before the fix. A struct member cannot ref-return its own field — CS8170 — so the ensure-then-share-copy shape is the correct one; the (pageBits)(b) reinterpret conversions keep compiling and, once the backing exists, write through shared storage.) (Guarded by the NamedArrayWrapper behavioral test — len, index read/write, and a write via the (*pageBits)(b) reinterpret observed through the original, values vs Go; cleared runtime’s 5 pallocBits → IArray CS1503 plus a −3 CS0021 cascade of named-over-array indexing, 86 → 74 with the copy overload below.)

copy from a defined slice type. copy(dst, src) where src is a named slice type — type pMask []uint32, runtime proc.go’s copy(nidlepMask, idlepMask) — cannot bind the generic copy<T1,T2>(in slice<T1>, in slice<T2>): the wrapper implements ISlice<uint32> but is not a slice<T2>, and generic inference does not see user-defined conversions, so resolution fell onto copy(slice<byte>, @string) (CS1503 ×2 per call). golib adds copy<T1, T2>(in slice<T1> dst, ISlice<T2> src)T2 infers from the implemented interface — copying element-wise through the interface indexer with the same min-length/convert semantics; a genuine slice<T> source still binds the more-specific slice/slice overload, so existing calls are unchanged. (Guarded by the same NamedArrayWrapper test — copy count/values plus post-copy independence of source and destination, vs Go.)

The wrapper also forwards the underlying’s field-box accessors. Taking the address of a wrapper’s field — &p.x on a *pinnerBits, where type pinnerBits gcBits (runtime pinner.go) — emits the box-accessor form Δp.of(pinnerBits.Ꮡx), whose owning type is the wrapper; without a forwarded accessor the static exists only on gcBits (CS0117). For every forwarded field (properties cannot be ref‘d and get none, matching the plain-struct template) the generator emits the accessor as a true ref through m_value into the underlying struct’s field: public static ref uint8 Ꮡx(ref pinnerBits instance) => ref instance.m_value.x; — a genuine ref chain into the wrapper’s own storage, so a write through the resulting box persists (a copy here would silently drop writes — the trap that sank an earlier pallocBits forwarding attempt). Emitted only when members are forwarded, which is exactly when m_value is mutable. (Guarded by the NamedTypeOverStruct extension — bump(&c.a) writes through the wrapper’s field address and the original observes it; cleared runtime pinner.go’s 3 CS0117, 89 → 86.)

Untyped constants in a typed-element context (append). Because an untyped constant renders as a bare C# int/double literal or an Untyped* wrapper, passing one as an append element to a typed slice trips C#’s overload resolution: append<T>(ISlice, params T[]) infers T from the element while the slice<T> overloads infer T from the slice, so append([]uint16, replacementChar) (or append(buf, 7, 8)) would pick slice<int> and fail (CS0121 / CS0029). The converter therefore casts an untyped numeric-constant append element to the slice’s element type, matching Go’s implicit conversion:

var a []uint16
a = append(a, replacementChar)   // replacementChar is an untyped const
a = append(a, 7, 8)
slice<uint16> a = default!;
a = append(a, (uint16)(replacementChar));
a = append(a, (uint16)(7), (uint16)(8));

The same cast reaches an untyped numeric constant referenced through a cross-package SELECTOR. isUntypedNumericConstArg had matched only a bare *ast.Ident, so append([]byte, tabwriter.Escape) (go/printer’s block builder — tabwriter.Escape is const Escape = '\xff', rendered as a golib UntypedInt) kept the ambiguity (CS0121 ×6). The gate now also inspects an *ast.SelectorExpr’s Sel constant object, casting the element to the slice’s element type: append(block, (byte)(tabwriter.Escape)). That same selector gate also feeds the deferred method-value arg cast — defer(Δsyscall.Seek, Ꮡfd.Value.Sysfd, curoffset, (nint)(io.SeekStart), ref ᒐ) (internal/poll fd_windows.cs) casts the const to the parameter type rather than the default-type wrap — and the regexp/syntax unicode.MaxRune append; both are equal-or-better and compile. A same-package untyped const (a bare ident) is unchanged. (Guarded by the CrossPkgUser extension — append([]byte, CrossPkgLib.Sep) (rune ':') and append([]rune, CrossPkgLib.Precision) (int 2), both cross-package untyped consts reached through a selector, output-compared vs Go; without the fix the appends are CS0121.)

The parameter-type cast also reaches the lambda form of a go/defer call, not just the method-value form. When the callee returns a value (or is a value-receiver method), visitGoStmt/visitDeferStmt force the temp-param lambda goǃ(ᴛ1 => f(ᴛ1), arg) (see A value-returning goroutine callee is wrapped in a discarding lambda); there the arg’s C# type drives ᴛ1’s inference, and the lambda body’s f(ᴛ1) then needs ᴛ1 to be f’s parameter type. An untyped numeric const otherwise took convExprList’s DEFAULT-Go-type cast, so ᴛ1 inferred the default (nint) and f(ᴛ1) failed — hash/crc32’s go MakeTable(Castagnoli) (Castagnoli an untyped uint32 poly) emitted goǃ(ᴛ1 => MakeTable(ᴛ1), (nint)Castagnoli), CS1503. convCallExpr now applies the parameter-type cast in the lambda form too, but ONLY when the parameter differs from the const’s default type (untypedNumericConstArgDefaultType vs the param’s underlying basic) — when they match, the existing default-cast path already yields the right type, so overriding would only churn the golden ((nint)x(nint)(x)). Proven zero-drift on the behavioral corpus and the full stdlib reconvert (the fix fires only where a wider/other parameter demands it). (Guarded by the GoUntypedConstArg behavioral test — go compute(poly) with a value-returning callee and an untyped uint32-poly const, output-compared vs Go; without the fix the goǃ arg is (nint)poly and the lambda body is CS1503.)

A string-literal spreadappend(b, "runtime error: "...) (runtime error.go’s message builder) — renders the literal as a "…"u8 ReadOnlySpan<byte>, which has no spread property (.ꓸꓸꓸ → CS1061). The spread emission wraps a direct string-literal source in the member-accessible @stringappend(b, ((@string)"runtime error: "u8).ꓸꓸꓸ) — whose ꓸꓸꓸ returns the Span<byte> the append<T>(slice<T>, params Span<T>) overload binds; this is the same wrap the string(r)... conversion spread uses (above). A non-literal spread source (a slice, a @string variable) is unchanged. (Guarded by the StringConvPostfix extension — two literal spreads appended and value-compared vs Go.)

A string-literal CONCAT as an object/interface vararg argument — runtime stack.go’s newline+tab join in print’s diagnostics — needs the same u8 suppression the direct literal argument already gets, propagated INTO the BinaryExpr’s operands: both halves otherwise render as "…"u8 spans, and a ReadOnlySpan<byte> cannot box to object (CS1503) nor be +-concatenated. The binary-expression conversion now honors an incoming BasicLitContext.u8StringOK=false, so the operands render as plain C# strings whose + and boxing are fine; the default context leaves every other path unchanged. (Guarded by the StringConvPostfix extension — a concat with an escape into an fmt.Println vararg plus a nested three-way concat, values vs Go.)

A []byte("literal") over a plain-text string literal feeds the zero-allocation u8 ROM span straight into the slice — []byte("hi")slice<byte>("hi"u8) — via golib’s slice<T>(ReadOnlySpan<T>) factory (which copies the span into the slice’s backing array), rather than routing the literal through a heap @string first (the older slice<byte>((@string)"hi") allocated an @string and then converted it to byte[]). Both the general []byte/[]rune conversion path and the u8 literal keep their existing forms elsewhere; only the specific plain-[]byte-literal case is retargeted, gated to exactly what convBasicLit renders as a u8 span: a []rune literal keeps @string (it needs @string’s rune decoding, not raw UTF-8 bytes); a high-\xHH-byte []byte literal keeps the byte-array-backed @string (its bytes do not round-trip through u8); a NAMED byte-slice type (type htmlSig []byte) keeps its wrapper cast; and a string variable is already an @string. Not ambiguous with the array slice<T>(T[]) builtin — a u8 literal is a ReadOnlySpan<byte> (an exact match for the new overload), while an @string converts to byte[] but not to a span. (Guarded by the StringLiteralSliceConversion extension — plain-text, raw-backtick, and high-\xHH-byte []byte literals plus []rune and string-variable controls, output-compared vs Go; and confirmed across ~144 stdlib sites by the full reconvert.)

Typed arguments and already-explicitly-converted elements (uint16(r)) are left as-is.

Relatedly, when the shifted (left) operand of a shift is an untyped constant — 1 << k — Go gives the whole shift the type it assumes from context (e.g. uintptr when compared with a uintptr), but the bare C# literal makes the result int, which then cannot compare or combine with the typed operand (CS0034). The shift result is cast to its resolved type:

var u uintptr = 7
_ = u < 1<<8   // 1<<8 takes type uintptr
uintptr u = 7;
_ = u < (uintptr)(1 << (int)(8));

The narrow-width flavor of the same shift-retype is a behavioral requirement, not just a compile fix: a sub-int-width left operand (int8/uint8/int16/uint16) promotes to int in a C# shift, so the shift computes at 32-bit width with no wraparound at the type’s own width — where Go computes a shift in the operand’s type. byte(200) << 1 is 144 in Go (wraps at byte width) but 400 in the promoted C# int shift. The shift result is cast back to the shift expression’s resolved Go type, so a var, typed-const, and untyped-const left operand all wrap alike (the untyped-const flavor was historically correct only via the wrapper retype above; typed left operands got no cast at all and produced the unwrapped value):

var cb byte = 200
var k uint = 1
fmt.Println(cb << k)   // 144: wraps at byte width
byte cb = 200;
nuint k = 1;
fmt.Println((byte)(cb << (int)(k)));

A whole-expression Go constant shift is skipped (Go constant arithmetic cannot overflow its type, and the wrap cast on a C# compile-time constant would even be rejected, CS0221), and right shifts take no cast (a narrow operand zero-/sign-extends into the int-width shift, so the result always fits the narrow width). A named narrow type routes through its underlying — (nb)(byte)(n << (int)(k)) — since a [GoType] conversion accepts only its exact underlying, never C# int. int32-and-wider left operands already shift at their Go width in C# and keep their existing forms. (Guarded by the NarrowShiftVarCount behavioral test — byte/uint16/int8/int16 left shifts by variable counts that overflow the narrow width, across var, typed-const, untyped-const, and named-type left operands, plus right-shift controls, output-compared vs Go.)

A C# compound shift-assignment (<<=/>>=) requires the shift count to be int; the count’s own (possibly unsigned/native-width) type is rejected — s.allocCache >>= (nuint)x is CS0019. So the count is cast to int (s.allocCache >>= (int)x). This applies whether the assignment target is a simple variable or a selector/pointer-field LHS (s.allocCache, a field reached through a *mspan) — both paths emit the same (int) count cast. (Guarded by the ShiftPrecedenceUnsigned behavioral test — simple-variable and struct-field shift-assigns with an unsigned count; runtime hits the field form in malloc/mbitmap’s allocCache bit walks.)

A related case is a computed constant mask under a native-int bitwise operator. i & ((1 << shift) - 1) or i &^ (blockSize - 1), where i is a uintptr/uint (C# nuint/uintptr) and shift/blockSize are native-int constants: the mask is a Go compile-time constant, but because the native const is emitted as a get-only property (package scope) or a plain local (function scope), not a C# const, the expression is not a C# constant, so it renders as a bare int — and nuint & int is CS0019 (no common type, and no implicit constant conversion since the operand is non-constant). The converter casts such a computed-constant operand to the native result type — (uintptr)i & (uintptr)((1 << (int)shift) - 1). A small bare literal (x & 7) is left alone (C#’s constant conversion fits it), but a large literal whose value exceeds the C# int32 range (uintptrMask & 0x00ffffffffff) is emitted by convBasicLit with its own (nint)/unsigned cast — so it is no longer a bare int and nuint & nint is CS0019 too; such a literal operand is cast to the native result type the same way (& (uintptr)(nint)1099511627775L). A named untyped-const reference is handled by the wrapper cast below. There is also a &^ (AND-NOT) twist: it is rendered & ~y, and ~ promotes its operand to int, so even a small constant operand (p &^ 15nuint & ~15 = nuint & (int)-16) is CS0019 — a negative int cannot convert to an unsigned native type, even as a constant. So a constant right operand of &^ with a native-int result is also cast to the native type, & ~(uintptr)15, performing the complement in that width (a non-constant native operand, p &^ mask, already complements correctly and is left alone). (All guarded by the NativeIntConstMask behavioral test — computed mask, large-literal mask, and small-literal &^; runtime exercises this in arena/page mask arithmetic such as arenaIndex/alignDown, mallocinit’s uintptrMask &, and os_windows’s ptr &^ 15 16-byte align.)

Similarly, when a named untyped numeric constant (emitted as the UntypedInt/UntypedFloat wrapper) is an operand of arithmetic with a concrete numeric type, the wrapper’s bidirectional implicit conversions can make the result resolve to the wrong type (a * two32, uint64 * UntypedInt, yields int — CS0029). The named-const operand is cast to the concrete operand’s type (comparisons resolve through the implicit conversion, so only arithmetic is cast):

const two32 = 1 << 32
var a uint64 = 100
_ = a*two32 + 3
UntypedInt two32 = /* 1 << 32 */ 4294967296;
uint64 a = 100;
_ = a * (uint64)two32 + 3;

A constant too large for int64/uint64 (or float64) is emitted as GoBigConst (= System.Numerics.BigInteger), which has no implicit operator with the built-in numeric types. Unlike an UntypedInt/UntypedFloat wrapper, that makes a bare reference a hard error in every concrete numeric context, not merely a resolution hazard in arithmetic — so the cast belongs to the reference itself (bigIntegerConstMaterialization), applied wherever go/types records a concrete numeric type on it:

const below1e23 = 99999999999999974834176
var ftoatests = []ftoaTest{{below1e23, 'e', 17, "9.99999999999999748e+22"}}
_ = x > Two129                     // Two129 = 1<<129
internal static readonly GoBigConst below1e23 = /* 99999999999999974834176 */
    GoBigConst.Parse("99999999999999974834176");
internal static slice<ftoaTest> ftoatests = new ftoaTest[]{
    new((float64)below1e23, (rune)'e', 17, "9.99999999999999748e+22"u8)}.slice();
_ = x > (float64)Two129;

Comparison was originally the only casting consumer (in convBinaryExpr), which left composite-literal elements, call arguments, typed var initializers, assignments, returns, and channel sends emitting bare — twelve BigIntegerdouble CS1503s in strconv’s ftoa_test.cs alone. Moving the cast to the reference serves all of them at once, and the comparison arm was dropped so it no longer double-casts ((float64)(float64)Two129). Two properties make the context type reliable: go/types records the converted type on the reference (inside []float64{…} the recorded type is float64, not untyped), and Go only admits a constant where its value is representable — so a BigInteger-backed value’s concrete context is necessarily float/complex, never a 64-bit integer that would overflow. The reference is kept readable rather than folded to a literal, the same call foldedNamedFloatConstLiteral makes for a bare reference. (Guarded by BigUntypedConstComparison, extended from comparison-only to every position, with an in-range UntypedInt const as the must-stay-uncast counter-control.)

An INTEGER expression over a GoBigConst constant folds — it has no 64-bit form

The (float64)Two129 cast above works because BigInteger converts to double. An integer target has no such luck: (uint64)mask on a 128-bit BigInteger throws System.OverflowException at run time. That is not a corner case — it is the shape of the Go standard library’s whole-width byte-classification bitmap idiom (go/doc/comment’s isHost/isPath/isIdentASCII/importPathOK, net/textproto’s validHeaderFieldByte/validHeaderValueByte), where a 128-bit untyped mask is legal precisely because Go requires only the FINAL value of a constant expression to be representable:

const mask = 0 | (1<<26-1)<<'A' | (1<<26-1)<<'a' | (1<<10-1)<<'0' | 1<<'_' | /* … */ 1<<':'

return ((uint64(1)<<c)&(mask&(1<<64-1)) |
	(uint64(1)<<(c-64))&(mask>>64)) != 0

Both halves are uint64-valued constants, so both must emit as the go/types-recorded folded value. mask&(1<<64-1) already did — its 1<<64-1 operand subtree exceeds int64, which constExprHasBeyondInt64UntypedOperatorSubexpr recognizes. The sibling mask>>64 has no such subtree: its only unrepresentable operand is the reference, which the shift path retyped to the shift’s resolved width (((uint64)mask).Rsh(64)) and threw. overflowingConstLiteral therefore also folds on constExprHasBeyondUint64UntypedConstRef — any PROPER subexpression that is a named untyped-const reference fitting neither int64 nor uint64 (the GoBigConst emission, isBigIntegerBackedConstRef):

GoBigConst mask = /* 0 | (1<<26-1)<<'A' | … */ GoBigConst.Parse("10633823862292363665388054147449749504");
return ((uint64)((uint64)((((uint64)1).Lsh((uint64)(c))) & (576284830442979328UL)) |
        (uint64)((((uint64)1).Lsh((uint64)((c - 64)))) & (576460746666278911UL)))) != 0;

Unlike the sibling overflow folds this one is magnitude-independent: the operator form does not merely compute in the wrong width, it throws, so a folded value that fits int32 (mask>>64 of 1<<70 | 1<<3 is 64) folds too. Scope: the unsigned arm covers uint64/nuint/uintptr; the signed arm is confined to the 64-bit-wide targets its …L / (nint)(…L) contract already covers (a narrower signed target keeps the operator form, where the wrapper cast fails LOUDLY rather than silently computing the wrong value — no such site exists in the stdlib corpus). The mask local itself stays emitted, unused, carrying the gofmt’d Go constant as its comment: it is what makes the folded magic numbers readable back to the Go source (its BigInteger.Parse hoists to a static field — see the next subsection).

Corpus footprint of the fold: exactly two files across the 302-package stdlib conversion (go/doc/comment/parse.cs, net/textproto/reader.cs), both still compiling clean. (Guarded by the UntypedConstWideMask behavioral test — the isHost mask, the &^-inverted validHeaderValueByte mask, a small-valued high half, and a uintptr-target native-width mask, all output-compared vs Go. Without the fold the uintptr arm is a hard CS0030 and the uint64 arms throw.)

A function-LOCAL GoBigConst hoists its parse to a static readonly field

A Go constant has no runtime existence — its value lives in the instruction stream — and GoBigConst is the one C# constant projection with a real per-evaluation cost: BigInteger.Parse allocates its bits array on every run. Emitted as a plain local, that parse re-ran on every call of the enclosing function; net/textproto’s validHeaderFieldByte paid it 14 times per canonicalMIMEHeaderKey call (560 B against Go’s 0) inside TestCommonHeaders’ want-ZERO testing.AllocsPerRun assert — and the local was not even referenced, every use having been folded by the subsection above. An int-kind function-local big constant therefore hoists its parse to one private static readonly field above the function (the hoisted-string-literal pattern), and the local initializes from the field — a BigInteger struct copy, which allocates nothing:

func validHeaderFieldByte(c byte) bool {
	const mask = 0 | (1<<(10)-1)<<'0' | /* … */ 1<<'~'
	
}
// Hoisted Go big-integer constant (single parse; Go folds constants at compile time)
private static readonly GoBigConst mask = GoBigConst.Parse("116972063611741436228934278030836105216");

internal static bool validHeaderFieldByte(byte c) {
    GoBigConst mask = /* 0 | (1<<(10)-1)<<'0' | … */
            mask;
    return ;
}

Field names are claimed package-wide (<name> + HoistedConstMarker + ordinal on collision — reader.cs declares maskᶜ and maskᶜ1 for its two functions’ masks), deterministic because files convert sequentially; a -tests internal variant seeds from the production conversion’s claims exactly as lifted type names do (productionHoistedConstOrdinals). Float/complex OVERFLOW constants keep the per-call parse: their exact string may be a rational ("1/3") whose Parse throws, and a field initializer would turn that per-call throw into a package-class TypeInitializationException. Package-level big consts were already static readonly fields and are unchanged. (Guarded by UntypedConstWideMask — four functions with local big-const masks, exercising the ordinal chain — and by net/textproto’s validated TestCommonHeaders, whose want-zero assert is what surfaced the cost; L11.)

The &^= (bit-clear) compound assignment on a narrow type

C# has no &^ (AND-NOT) operator, so Go’s a &^= b expands to a &= ~b. The ~ complement always promotes its operand to int, and int is not implicitly convertible to a narrower or unsigned LHS type (byte/ushort/uint/ulong/uintptr/nuint) — so flags &= ~b is CS0266. The complemented value is therefore cast back to the LHS type, inside unchecked because for a constant operand ~b folds to a negative int constant whose checked narrowing would overflow (CS0221):

h.flags &^= hashWriting   // h.flags is uint8
h.Value.flags &= unchecked((uint8)~hashWriting);

An LHS type that int widens to implicitly (int/int32/int64) needs no cast and stays a &= ~b. (Guarded by the AndNotAssignNarrow behavioral test, which exercises both an ident LHS and a struct-field LHS — they route through different assignment-emission paths.)

A standalone ^x on uint8/uint16 truncates back to the operand’s type

The same int promotion has a silent-value face, not just a CS0266 face. Go’s ^x has x’s own type, so on a sub-int UNSIGNED type the complement wraps to that width — ^uint16(5) is 65530. C# promotes byte/ushort to int first, so bare ~x is -6: identical in the low 16 bits, but every widening use then carries the sign bits, and no cast is required to make it compile:

w.writeBits(int32(^uint16(length)), 16)     // compress/flate, stored-block header
w.writeBits((int32)((uint16)(~(uint16)length)), 16);

Without the inner truncation this wrote -6, and writeBitsbits |= uint64(b) << nbits sign-extended it across the whole 64-bit accumulator — so every level-0 (NoCompression) DEFLATE stream was garbage and the decoder rejected its own encoder’s output with flate: corrupt input before offset 59. It compiled clean and only compress/zlib’s TestWriter, which round-trips at every level, caught it.

Only unsigned uint8/uint16 need this. A signed narrow type is already value-correct (C#’s ~ of a sign-extended operand equals the sign-extended Go result: int32(^int16(5)) is -6 in both languages), and every type at least 32 bits wide (uint, uint32, uint64, uintptr, all signed widths) keeps its own type under C#’s ~. A NAMED type routes through its golib wrapper’s operator.

A CONSTANT operand needs one more word. The truncation is then a C# constant conversion, and those are checked at compile time no matter what the enclosing context says: ~(ushort)0 promotes to the int -1, and (ushort)(-1) is a hard CS0221 — however correct the runtime truncation would be. The all-ones idiom is exactly that shape, and it is the bound x/net/dnsmessage compares each section count against, seven times in one file:

if len(m.Questions) > int(^uint16(0)) {     // vendor/golang.org/x/net/dns/dnsmessage
if (len(m.Questions) > (nint)(unchecked((uint16)(~(uint16)0)))) {

So a Go-constant operand states unchecked; a variable operand does not, since C#’s default context is already unchecked and the keyword would only add noise at flate’s ^uint16(length) sites.

Where the result is immediately narrowed back by the surrounding narrow-arithmetic cast the inner truncation is redundant — takeU8(^a) renders takeU8((uint8)((uint8)(~a))). That is accepted cosmetic noise: the two forms are value-identical, and the alternative (deciding at the unary site whether the enclosing context widens) trades a silent-corruption hole for readability. (Guarded by the AndNotAssignNarrow behavioral test’s widening cases — int32(^uint16(x)), uint64(^uint8(x)), the uint32/int16 no-op controls, the narrow round-trip, and the constant cases int(^uint16(0)), int(^uint8(0)) and uint64(^seed) over a typed uint16 const.)

Logical operators on a named boolean type cast through bool

A Go defined type whose underlying type is bool (type boolVal bool) is modeled as a [GoType("bool")] struct with an implicit bool conversion but no logical operators. Go’s !, &&, and || on such a value yield that same named type, so return !y / return x && y in a function returning an interface the type implements (go/constant’s UnaryOp/BinaryOp, returning the Value interface) still satisfies the interface. A bare !y / x && y in C# collapses to a plain bool — which cannot implicitly convert to the interface (CS0029), and ! has no operator on the struct (CS0023). The converter casts each operand through bool, applies the operator, then casts the result back to the named type so it keeps satisfying the interface:

case boolVal:
    return !y          // y is boolVal, result must be the Value interface
case boolVal y: {
    return ((boolVal)(!(bool)y));
}

Binary &&/|| take the parallel form ((boolVal)((bool)x && (bool)y)). A predeclared-bool operand keeps the bare !x / x && y form (no golden churn). (Guarded by the NamedBooleanLogic behavioral test.)

Casting a negative value to a non-keyword type parenthesizes the operand

C# parses (T)-value as a cast only when T is a keyword primitive (int, long, nint, byte, …). For a using-alias (int64=long, uint64=ulong, rune=int, …) or a [GoType] named type (level), (int64)-1 / (level)-1 is instead parsed as type MINUS value — CS0075 (“to cast a negative value, you must enclose the value in parentheses”) and CS0119 (“‘long’ is a type, not valid in the given context”). So a cast whose operand leads with a unary +/- and whose target is not a C# keyword type parenthesizes the operand:

lvl := level(-1)              // named conversion
mask := -1 << uint(bits)      // int64-typed wide shift
var lvl = ((level)(-1));
var mask = ((int64)(-1) << (int)((nuint)bits));

Two emission sites carry it: the type-conversion cast (convCallExpr, castOperandNeedsParens) covers level(-1)/int64(-1), and the wide-shift left-operand cast (convBinaryExpr) covers -1 << bits (a wide shift type does not promote to int, so its left operand is cast to that type). A keyword target ((int)-1, (nint)-1) and a non-negative operand keep the bare form (no golden churn). (Guarded by the CastNegativeNamedType and ShiftNegativeWideConst behavioral tests.)

A named complex type emits only Go’s complex operator set

The generated named-numeric wrapper (go2cs-gen InheritedTypeTemplate/NumericTypeTemplate) emits the operator surface of the underlying kind, and Go’s complex kinds define only ==/!=, +/-/*//, unary -, and ++/--no ordered comparisons and no % (the Go spec limits </<=/>/>= to ordered types and % to integers; C#’s System.Numerics.Complex and golib complex64 have neither operator either). A type C complex128 therefore gets no </<=/>/>=/% operators and no IComparisonOperators interface declaration — emitting them was CS0019 ×5 per type (first hit: testing/quick’s TestComplex64Alias/TestComplex128Alias, which compile-blocked the whole quick test host). Integer named types keep the full set including %/bitwise/shifts, and float named types keep ordering (and C#’s native float %, inert for converted Go, stays). Same kind-gate shape as the pre-existing complement/shift gate (GetComplementOperator). Guarded by the NamedNumericIncDec behavioral test’s named-complex block (++/--/arithmetic/equality on a type cx complex128).

Floating-Point Formatting

Go’s default rendering of a float — %v, %g, and the bare Println/Print/Sprint paths — is strconv.FormatFloat(f, 'g', -1, bitSize): the shortest decimal digits that round-trip back to the same float, laid out in 'e' form when the decimal exponent is below -4 or at/above 6, and 'f' form otherwise. The exponent is lowercase, always signed, and always at least two digits.

.NET’s default/"R" formatting also produces shortest-round-trip digits, but presents them differently: it flips to exponent form on its own thresholds and writes an unpadded, uppercase exponent. The two agree far more often than they disagree, which is what makes the disagreement easy to miss — the gap only opens at the ends:

fmt.Println(1000000.0)                   // Go: 1e+06        .NET default: 1000000
fmt.Println(1e-5)                        // Go: 1e-05        .NET default: 1E-05
fmt.Println(2.2250738585072014e-308)     // Go: …e-308       .NET default: …E-308
fmt.Println(999999.0)                    // Go: 999999       .NET default: 999999   (agree)

The 6 is the whole story for %v: it is why 1000000.0 prints as 1e+06 while 999999.0 prints in full, and it comes from strconv’s formatDigits, which pins the exponent threshold to a flat 6 whenever the digits were the shortest round-trip (if shortest { eprec = 6 }) rather than to the requested precision. The threshold is not 21 — that figure appears in an older reading of the rule and matches JavaScript’s Number.toString, not Go’s.

The conversion: the baseline stub fmt (src/core/fmt/format.cs) reproduces strconv’s layout rather than delegating to .NET’s presentation. It leans on an empirical equivalence, verified by differential fuzzing (below): .NET and Go produce the same digits. .NET’s "R" is the same shortest round-trip, and its "E<n>" rounds the exact binary value to n+1 significant digits with the same round-half-to-even that strconv’s shouldRoundUp applies — including denormals, and exactly (not zero-padded) well past 17 digits, so %.40e of 0.1 agrees digit-for-digit. Only the presentation differs. So FormatFloat takes .NET’s digits, reduces them to strconv’s decimalSlice shape (DecomposeDigits: significant digits, sign and trailing zeros stripped, scaled so the value is 0.<digits> × 10^dp), and lays them out through direct ports of strconv’s fmtE and fmtF. Fixed-point (%f with a precision) is the one case handed to .NET whole — "F<n>" already matches fmtF exactly, and it wants digits at a decimal place rather than a significant-digit count.

Verb defaults follow Go’s fmt: %v renders as %g, %F as %f; %e/%f default to a precision of 6 and %v/%g to the shortest round-trip; an explicit precision overrules either. float32 resolves its digits as a single (((float)value).ToString(…), never widened to double first), so float32(1.0/3.0) prints Go’s 0.33333334 and not the double’s 0.3333333333333333. Two Go quirks that a straight reading of the rule misses, both caught by the fuzz:

Verification. Beyond the fixed cases, a differential fuzz compared the stub against go run over random float64/float32 bit patterns (so NaNs, infinities and denormals arise naturally), precisions 0–20 across %g/%e/%f/%G/%E, and values clustered on the threshold where the form flips: ~50,000 formatted values over six seeds, all byte-identical. The %.0g promotion above was found this way — the hand-picked cases all passed without it.

Scope. This is the hand-written baseline stub fmt, the proxy the behavioral corpus builds against. The full conversion’s fmt calls the converted strconv, which is Go’s own digit code and needs none of this. (Guarded by the FloatFormatExponent behavioral test — both thresholds from either side, 1e20/1e21, 1000000.0, the math.MaxFloat64/SmallestNonzeroFloat64 values, float32 counterparts, negative zero, and every verb with and without precision, byte-compared against go run; ±Inf/NaN flag interactions live in PrintfWidthFlags. The extreme values are spelled as literals because the converted math package’s constants are themselves rendered lossily — MaxFloat64 as 1.79769e+308 — which is a separate converter defect, deliberately not conflated with this one.)

A folded constant of a NAMED type carries its type in the fold

overflowingConstLiteral materializes a compile-time integer constant whose value falls outside the C# int32 range, because C# would otherwise evaluate the operator expression in int32 and overflow (CS0220). It read the constant’s type through Underlying(), so a constant of a defined type folded to a bare basic literal and the Go type was simply lost:

d := 8 * time.Hour
secondsEastOfUTC := int((8 * time.Hour).Seconds())
var d = 28800000000000L;                            // a C# long, not a Duration
nint secondsEastOfUTC = (nint)(28800000000000L).Seconds();   // CS1929 — long has no Seconds

The compile error is the loud half; the silent half is d, which is now a long and prints as its digit count where a Duration prints 8h0m0s. The fold now carries the named type in the same parenthesized (T)(…) shape the native-int arm uses — (time.Duration)(28800000000000L) — which wholeExprIsCastOfType already recognizes, so enclosing paths do not re-wrap it. The [GoType] wrapper converts implicitly from its underlying, so the cast is always legal, and Go’s own parentheses around a method-call receiver keep the postfix .M() binding to the cast rather than to the literal. Only constants outside int32 reach this arm at all, so the corpus footprint is the handful of computed time.Duration-class constants above that magnitude. (Guarded by the PackageNameShadowing behavioral test, case 4.)

Nil and Zero Values

In Go, nil is the equivalent of C# null. Where possible, converted code uses the golib NilType with a default instance called nil (defined in go.builtin). NilType provides comparison operators so x == nil / x != nil work across the runtime types (slices, maps, channels, pointers, interfaces), each of which defines what “nil” means for it (e.g. a map<K,V> whose backing dictionary is null is the nil map: reads return the zero value, len is 0, ranging yields nothing, and a write panics — matching Go).

The same null-safe-zero-value principle applies to value types whose backing store is a reference. A zero-value string converts to @string s = default!, which runs no constructor, so the backing byte[] is null. Rather than NRE on the first read, @string treats a null backing as Go’s empty string "" for every read — length 0, no bytes to index/range, == "" is true, prints empty, and concatenation yields the other operand (var s string; s += "x""x"). Constructors still allocate, so only the default(@string) zero value relies on this. (Guarded by the StringZeroValueConcat behavioral test.)

The THREE deref accessors of ж<T> — when each is needed, and how the converter picks

Establishing a local ref over a heap box (ref var p = ref Ꮡp.<accessor>) looks like one operation but encodes different answers to one question: is this access the Go DEREFERENCE, and what does Go say happens on nil at exactly this point? Consolidated here because the members landed across separate arcs (their individual sections, linked below, carry the full derivations); this is the map.

Accessor On nil The Go semantics it encodes How the converter KNOWS
.Value panics immediately (Go’s message, even on bind) this access IS the deref, and Go panics here — the ordinary pointer USE site (*p, ~Ꮡp, a read through the box) the DEFAULT everywhere except a pointer’s ENTRY alias; no special case applies
.ValueSlot no check — the slot as-is a read of the HELD value, never a deref: when the pointee is itself reference-like, *p legally yields nil (*(&err) of a nil error panics in neither language), so .Value’s null check would fire SPURIOUSLY on a legally-held null. Identical to .Value’s slot in every non-throwing case. Also where nil is structurally impossible (a freshly make-allocated box, heap(out …)) and in the reflection bridge’s field paths. by the POINTEE’S TYPE or by CONSTRUCTION — a box-of-pointer LOCAL, a named-result box, the bridge’s field walk. NOT at a pointer’s entry alias (see below)
.DerefOrNull() defers — binds Unsafe.NullRef<T>, faults with Go’s panic on first USE Go defers the panic to the body’s own deref point: passing a nil *T to a function, or calling a method through one, is legal; the body RUNS, a side effect before the deref must happen, and the panic lands where Go’s would — after it, or never (delegated checkValid-style guards). STRUCTURALLY — EVERY direct-ж pointer ENTRY alias, RECEIVER and PARAMETER alike, unconditionally (no analysis, because the accessor is faithful whether or not the body guards), plus the pointer-reassignment re-alias and go2cs-gen’s ReceiverMethodTemplate bridge; see A nil RECEIVER is nil-deferring, not nil-safe and A pointer PARAMETER is nil-deferring for exactly the reason a receiver is

Why three and not one: the ENTRY alias and the USE site are different questions, and .Value answers the second. .ValueSlot is different in KIND rather than in timing — it marks accesses that were never dereferences in Go’s semantics at all, which no nil-policy accessor can express — but it is not selected at an entry alias, where nothing can know whether the body will dereference and the nil-policy question is the only one being asked.

There used to be a fourth, .DerefOrNil() — a nil-SAFE accessor handing back a shared default(T) slot — and its retirement (2026-08-02) is what collapsed the set. It was admitted by a body ANALYSIS: a pointer param the body nil-compares, one passed the untyped nil at a same-package call site, or one whose first mentioning statement re-points it without dereferencing (l = l.get() normalization). Wherever that analysis was RIGHT the silent zero was unobservable; wherever it was wrong — and it could never be complete, because a body’s guard may be DELEGATED to a callee it merely hands the pointer to — a deref Go says must panic instead read a silent zero. Unifying every pointer entry alias on .DerefOrNull() made the analysis unnecessary in the first place, so the accessor, the three analyses that fed it (collectNilSafePtrParams, reassignedBeforeDerefParamName, and the package-wide nil-argument pre-pass) and their vestigial receiver arms were deleted together — 382 net lines of converter. The golib method survives with its own unit coverage, but converted code no longer emits it.

Canonical typed-nil pointer boxing

Go’s typed nil is a real value: any((*T)(nil)) is a non-nil interface carrying dynamic type *T, %T prints *T, and the pervasive descriptor idiom reflect.TypeOf((*T)(nil)).Elem() resolves the pointee type. A bare C# null erases all of that, so a nil→pointer conversion renders in pointer context and yields the type’s canonical typed nil instance — one shared, write-protected instance per pointer type (ж<T>.NilBox; a generated named-pointer wrapper’s NilInstance), which the NilType implicit conversion returns:

var errorType = reflectlite.TypeOf((*error)(nil)).Elem()
var x any = (*int)(nil)   // x != nil; %T prints *int
internal static reflectliteType errorType = reflectlite.TypeOf(((ж<error>)nil)).Elem();
any x = ((ж<nint>)nil);

Supporting semantics, all structural (m_isNull — never the value-peeking IsNull, which remains the dereference guard):

Every consumer that asks “is this THE nil pointer” must ask the structural predicate. The managed-slot atomic.Pointer<T> (core/sync/atomic/type.cs) canonicalizes the nil pointer to a null slot so a reference CompareAndSwap treats all nil *T values as equal — and its nilCanon helper asked the value-peeking IsNull, so it collapsed a pointer to a nil value to nil as well. sync.Map is built out of exactly that shape and lost both halves of it: e.p.Store(&i) with a nil any value dropped the entry outright (load()’s p == nil then reported not-ok, so Range skipped it and CompareAndSwap failed against it), and the expunged = new(any) sentinel — a real address holding a nil interface — became indistinguishable from nil, so a deleted entry could not be told from an expunged one and the whole dirty/expunge protocol degenerated. The predicate is now ж<T>.IsNilPointer. The same conflation applied to atomic.Pointer[error], atomic.Pointer[func()] and any **T slot (atomic.Pointer[*T]), all present in the corpus. (Guarded by AtomicPointerToNil: Load/Store/Swap/CompareAndSwap over a pointer to a nil any, two distinct new(any) sentinels, a pointer to a nil *int, and the genuinely-nil slot, output-compared vs go run. Before the fix the guard panics with a nil-pointer dereference on its second line.)

(*T)(nil) conversion expressions are where the canonical instance is minted, and pointer locals, parameters and fields keep plain null — their statically-typed world never needs the type carried. The type is carried where it becomes observable instead: at the boundary into interface space (below).

A target written as a pointer to a composite type literal(*[]byte)(nil), (*map[string]int)(nil), (*struct{ r int })(nil) — reaches the same rendering by a different route. isTypeConversion resolves a star target through its types.Object, which a type literal does not have, so these shapes fell through to the regular call path and emitted a bare cast of default! ((ж<slice<byte>>)(default!)) — a null reference, type erased. They are now claimed as conversions, and the typed-nil interception renders the target through convStarExpr rather than the resolved name, because an anonymous struct/interface element must be LIFTED to a named C# type and convStarExpr is the site that performs that lift (the plain name path emits an unresolvable raw struct{…} signature). encoding/gob’s bootstrapType table is the whole idiom in one place, and it is a package-init NRE without this — every reflect.TypeOf(…).Elem() in it saw a null descriptor:

tBytes     = bootstrapType("bytes", (*[]byte)(nil))
tReserved7 = bootstrapType("_reserved1", (*struct{ r7 int })(nil))
internal static typeId tBytes = bootstrapType("bytes"u8, ((ж<slice<byte>>)nil));
    [GoType("dyn")] partial struct Δtype {
        internal nint r7;
    }
internal static typeId tReserved7 = bootstrapType("_reserved1"u8, ((ж<Δtype>)nil));

Guarded by TypedNilInterface (extended with the slice/map/anonymous-struct type-literal targets), PointerToNilPointerIdentity, and NamedPointerReinterpret (the canonical singleton keeps its object-reference nil compare working); part of the reflection-bridge Phase-3 chip (see docs/phase4/DESIGN-reflection-bridge.md).

reflect.Value.Interface() is a boundary into interface space, so it packs the typed nil too

The rule above says the canonical instance is minted where the type becomes observable — at the boundary into interface space — and pointer slots themselves keep plain null, because their statically-typed world never needs the type carried. reflect.Value.Interface() is one of those boundaries, and it is the one a slot read arrives at: the Value’s data came out of a slice element, an array element, a struct field, a map value or a reflect.New(...).Elem(), all of which hold null for a nil *T. Handing that null straight out erases the type at the one call whose entire job is to preserve it.

Go’s own form makes the obligation explicit. packEface builds an interface from a type and a data word, so a pointer-kinded Value with a nil data word packs as a non-nil interface holding (type=*T, value=nil). Managed storage has no data word to keep the type beside, so the bridge reconstructs it from the Value’s static type — which makeTypedValue recorded when the Value was built — and re-encodes a null pointer-kinded read as that type’s canonical typed nil:

in := make([]*Int, 1)              // one zero-filled *Int element
v := reflect.ValueOf(in).Index(0)
i := v.Interface()                 // (*Int)(nil), NOT nil
data, err := i.(GobEncoder).GobEncode()   // assertion SUCCEEDS; nil receiver dispatches

The consumer that proves it is encoding/gob: encodeGobEncoder is literally v.Interface().(GobEncoder).GobEncode(), and big.Int.GobEncode opens with if x == nil because Go guarantees it will be reached that way. With the type erased, i == nil is true, %T prints <nil>, the assertion takes its failure arm, and the nil-receiver method never runs — which is the whole of math/big’s TestGobEncodingNilIntInSlice / TestGobEncodingNilRatInSlice.

Two boundaries of the rule, both load-bearing:

The fabrication path (reflect.Zero) and the write path were already on this encoding; this is the read path joining them, so there is one nil encoding system-wide rather than two.

Guarded by ReflectTypedNilInterface, which runs typed nil → Interface()== nil → type assert → nil-receiver dispatch across every slot kind that funnels through makeTypedValue, each paired with a non-nil sibling so a blanket substitution fails as loudly as the erasure did, and pins Elem() of a typed nil as still invalid (how a walker tells a typed nil from a pointer to a zero value).

The emission side is a separate, open question. A typed nil crossing into an interface in ordinary converted code — not through reflection — still collapses, because the pointer slot really does hold null and the conversion site is not always able to see that it needs the box. Closing that changes what == nil means for every converted interface and is a design decision, not a fix; encoding/gob’s own TestNilPointerInsideInterface is its standing witness.

A pointer crossing into an interface carries its static type, however the pointer was produced

The rule the boxing above is a special case of:

A Go POINTER entering INTERFACE space is represented by its pointer BOX, carrying its static pointee type — however the pointer was produced.

Go’s interface value is a (dynamic type, value) pair, so a pointer in one is never merely an address: any((*T)(nil)) is a non-nil interface whose %T prints *T and whose x.(*T) assert succeeds with a nil result. Two managed renderings break that, and both silently:

A non-empty interface target already satisfies the rule and takes nothing from this: its conversion goes through a generated adapter that holds the box and null-coalesces it to the same canonical instance (AdapterImplTemplate). The empty interface (any) has no adapter — C# boxes the value directly — so the treatment is emitted, as the golib ж<T> extension OrTypedNil() (box ?? ж<T>.NilBox; TypedNilBoxAccessor):

var ip *int
var st struct{ P *AErr }
sl := make([]*BErr, 1)
fmt.Printf("%T %T %T\n", ip, st.P, sl[0])   // *int *main.AErr *main.BErr
ж<nint> ip = default!;
main_st st = default!;
var sl = new slice<ж<BErr>>(1);
fmt.Printf("%T %T %T\n"u8, ip.OrTypedNil(), st.P.OrTypedNil(), sl[0].OrTypedNil());

Why the BOUNDARY and not the producers. Emitting the canonical instance for a pointer’s zero value instead would be incomplete by construction: var p *T is an emission the converter owns, but a struct’s zero value, a make([]*T, n) element and a map miss are not — they are C# default, which no emission intercepts. The boundary is finite and enumerable; the producers are not. It is the same set of slots an untyped constant boxed as any already routes through, for the same reason (a value’s Go dynamic type must survive being boxed): call arguments including the variadic ...any of the fmt family, composite-literal elements, keyed and positional struct fields, map keys and values, var initializers, assignments, returns, channel sends, and an explicit any(p) conversion. (The positional form is the one encoding/gob’s TestNilPointerPanics table is written in — []struct{ value any; mustPanic bool }{{nilStringPtr, true}, …} — where every nil row has to arrive as a typed nil for gob to panic on it as Go does.)

Three BUILT-INs take an any slot without ever passing a declared parameter, so each applies the boundary at its own emission arm rather than through the parameter loop: panic’s value (a recovered typed nil must still answer r.(*T)), an []any append element (the existing element-to-any cast then wraps the result), and an any-keyed map’s delete key — which has to box to the same value the store did, or it matches no entry. None of the three has a corpus site today (every panic argument in the standard library is an address-of composite, so the census is zero), but they are the same boundary and the guard exercises all three.

The scope is a genuine *T (a types.Pointer). unsafe.Pointer is a Basic and renders as a struct, and a NAMED pointer type renders as its generated wrapper struct — neither can be a null reference, so neither has anything to carry. An address-of (&x), a new(T), a (*T)(nil) and a pointer CONVERSION of any of those ((*Buffer)(&b), log/slog’s buffer pool) render non-null by construction and are left bare.

Corpus footprint: 465 sites across 145 files (A/B of two seeded whole-stdlib reconverts), plus one site where the deref-alias preamble became dead because the pointer now renders as its box — net/http’s http2h1ServerKeepAlivesDisabled(hs *Server), whose var x any = hs had been boxing a Server VALUE and then interface-asserting it, so its doKeepAlives probe could never match the pointer method set. Guarded by the extended TypedNilInterface (declared/field/element/map-miss nils through every one of those slots, %T, ==, and a type assert, output-compared vs go run; before the fix it reports dpi==nil true where Go says false and panics dereferencing a nil box on the return path).

Pointer-to-interface assignment through selector fields

A selector assignment whose LHS field is an interface (h.d = s) uses the type of the whole selector expression, not just the selected identifier name, when deciding whether to wrap the RHS in an interface adapter. If the RHS is a pointer-typed identifier, the adapter receives the pointer box so a dereferenced value alias is not copied into a pointer-only implementation. The generated form matches other pointer-to-interface conversion sites:

func assignDescriber(h *holder, s *Setting) {
    h.d = s
}
internal static void assignDescriber(ж<holder> h, ж<Setting> s) {
    ref var h = ref h.Value;
    ref var s = ref s.Value;

    h.d = new SettingжDescriber(s);
}

This is intentionally keyed on selector/index expression type instead of the root identifier, so struct fields such as go/typesoperand.expr ast.Expr and ordinary behavioral fields both take the same path. Guarded by PointerInterfaceStructField, including the assignment case after the struct-literal cases.

Empty Interface (any)

In Go, every type satisfies the method-less interface interface{}, now spelled any. This operates fundamentally like .NET’s System.Object, so the converter maps the Go empty interface to any (a global alias for object). For example, a Go func(i interface{}) becomes void f(any i), and a map[any]string becomes map<any, @string>.

A string literal in an any slot boxes through @string — as (@string)"…"u8

A Go string literal normally emits as a "…"u8 ReadOnlySpan<byte> (which converts implicitly to @string). But a ReadOnlySpan<byte> has no conversion to object, so a string literal RETURNED (or returned as a tuple element) where the result type is the empty interface fails with CS0029 — testing’s func (f *chattyFlag) Get() any { return "test2json" }. Such a result must box a golib @string (preserving Go string identity for a later x.(string) assertion), so visitReturnStmt renders the literal as (@string)"…"u8 for an empty-interface result element:

[GoRecv] internal static any Get(this ref chattyFlag f) {
    if (f.json) {
        return (@string)"test2json"u8;   // NOT a BARE "test2json"u8 (CS0029)
    }
    return f.on;
}

The cast and the u8 suffix are independent decisions. The cast is what the slot requires — it turns the span into an @string, which boxes with Go’s string dynamic type. The suffix then just decides where the literal’s bytes come from: u8 makes them a compile-time constant in the assembly’s data section, while a bare C# "…" is a UTF-16 constant that Encoding.UTF8.GetBytes has to transcode on every evaluation of the site (measured 2.1–2.4× for ASCII, 4.2× for non-ASCII). The two were coupled for a while — the emitter derived “no u8” from “needs the cast” — and every any position paid the transcode. They are now separate flags (castToGoString, u8StringOK), and every any slot takes the combined form.

The coupling had a second, load-bearing consumer: convBinaryExpr suppressed u8 inside a string concatenation whenever the enclosing slot had u8 off, because two u8 operands fold (C# folds UTF-8 literal constants) into a single ReadOnlySpan<byte> that then has no boxing conversion — print("\n" + "\t") in runtime’s newstack diagnostics is CS1503. Splitting the flags would have silently re-enabled u8 there, a compile break that CNR cannot surface (the emitted text is legal-looking; only the corpus build fails). Concat suppression therefore has its own signal, BasicLitContext.spanTargetUnsupported, which says “this slot cannot hold a bare span” and is set by every span-hostile site — any positions, ValueTuple elements, attribute (struct-tag) arguments, panic’s object parameter, a deferred call’s generic type-parameter slot — independently of how a STANDALONE literal renders there. It propagates into nested operands, so "a" + "b" + c stays suppressed all the way down.

resultParamIsInterface excludes the empty interface (andNotEmptyInterface), so the interface-conversion arm never fires for any; the per-element context sets castToGoString on (and leaves u8StringOK on) instead. Only string basic-literals consult those flags, so a non-string any result is unaffected. Also corrects a latent semantic bug in the multi-result form (return "<no value>", true from a (any, bool) result rendered a raw C# string, which would fail a Go x.(string) assertion). Guarded by InterfaceCasting.

The same boxing applies to an assignment whose target’s static type is the empty interface — a plain local (arg = "<nil>", go/types format.go’s sprintf over an any range variable, CS0029), a selector/index target (h.value = "field"), and a mixed-statement reassignment all render the literal (@string)"…"u8. visitAssignStmt threads the same castToGoString-on literal context into each RHS conversion site when lhsIsEmptyInterface reports the target is any (the NON-empty interface wrap stays with convertExprToInterfaceType, which the empty interface deliberately bypasses). (Guarded by the AnyStringLitAssign behavioral test — an any local, an any-typed range variable, and an any struct field each assigned a string literal, then type-switched on string, output-compared vs Go.) The same boxing applies to every composite-literal position whose declared slot type is the empty interface — the interface-wrap machinery deliberately bypasses any there too, so a string-literal element otherwise renders either as the u8 span (no conversion to the generated object slot — CS1503/CS0029) or as a bare C# string (compiles, but boxes a System.String, so a later Go x.(string) assertion or case string: fails at runtime):

type pair struct { label string; value any }
p  := pair{"tag", "val"}          // positional field
n  := &node{inner: "hi"}          // keyed field (typed, elided, and pointer-elided forms alike)
m  := map[string]any{"k": "mv"}   // map value
mk := map[any]int{"ky": 7}        // map key
s  := []any{"a", "b"}             // slice/array element
sp := [3]any{1: "sp"}             // sparse-array element
var p  = new pair("tag"u8, (@string)"val"u8);        // NOT bare "val" (wrong boxed identity)
var n  = (new node(inner: (@string)"hi"u8));        // NOT a BARE "hi"u8 (CS1503)
var m  = new map<@string, any>{["k"u8] = (@string)"mv"u8};
var mk = new map<any, nint>{[(@string)"ky"u8] = 7};
var s  = new any[]{(@string)"a", (@string)"b"}.slice();
var sp = new array<any>(3){[1] = (@string)"sp"u8};

(p’s FIRST element is the plain string field — a positional struct element in a string slot now renders u8 too, matching what the keyed and elided forms already emitted; the slice/array any ELEMENT form is the one position still on the bare-cast rendering.)

Keyed elements resolve their target slot in convKeyValueExpr (struct field via info.Uses; map/sparse element and map key via the threaded composite type) and take the same castToGoString-on literal context; positional struct fields and slice/array elements flip the per-element flags (useGoStringArg on, u8StringArgOK left on) that convExprList feeds each element’s literal context. A TYPE-PARAMETER slot is excluded even though its underlying constraint is an interface (isEmptyInterfaceTarget) — a ~string-constrained field takes the literal directly. Only string basic-literals are affected; every non-any slot keeps its exact prior form. (Guarded by the AnyStringLitComposite behavioral test — all the shapes above, each read back through a string type-switch to prove runtime identity, output-compared vs Go.)

The same boxing applies to a channel send whose element type is the empty interface — both the statement form and the select-case registration form. The send value previously converted with no target-type context at all, so the literal’s default "…"u8 span failed against the channel’s in object send parameter (CS1503):

ch := make(chan any, 1)
ch <- "text"                    // statement send
select { case ch <- "sel":  }  // select-case send (registration form)
ch.ᐸꟷ((@string)"text");                              // NOT "text"u8 (CS1503)
switch (select(ch.ᐸꟷ((@string)"sel", ꓸꓸꓸ))) {  }    // registration form takes the same box

Both send positions route through a shared convSendValueExpr (visitSendStmt.go), which resolves the channel’s ELEMENT type and applies the same isEmptyInterfaceTarget/isStringBasicLit gate as the assignment and composite-literal positions (a type-parameter element is excluded; only string basic-literals are affected). The same helper also activates the NON-empty interface element wrap — see Maps and Channels. (Guarded by the AnyStringLitChanSend behavioral test — statement and select-case sends read back through a string type-switch and an x.(string) assertion to prove runtime identity, output-compared vs Go.)

An untyped constant boxed as any boxes at Go’s DEFAULT TYPE

The numeric twin of the @string boxing above. Go materializes an untyped constant into an interface at its default type: untyped int → int (go2cs nint, an IntPtr), untyped rune → rune (int32), untyped float → float64. The boxed CLR type must match, because every observation of an interface value dispatches on it: x.(int) (emitted x._<nint>()) panics on a boxed Int32 (interface conversion: interface {} is int, not int — both sides print “int”, but one is Int32, one is nint); a case int: type switch falls through; golib AreEqual bails early on leftType != right.GetType(); and fmt’s printArg type-switch drops to its reflection fallback. Two renderings need a cast, for different reasons:

The cast applies at every empty-interface position — call argument (variadic ...any included), var-spec, assignment, return, channel send, slice/array element, keyed struct-field, map value, map KEY (composite and index alike), and an explicit any(...) conversion:

fmt.Sprintf("%s.v%d.%d", GOARCH, 8, i)   // variadic ...any argument
fmt.Sprintf("%s%c%03d", d, os.PathSeparator, seq)  // named untyped RUNE const under %c
v.Store(42)                  // non-variadic any argument (atomic.Value.Store)
var a any = 7                // var-spec
b = 8                        // reassignment
func r() any { return 42 }   // return
ch <- 3                      // channel send (chan any)
_ = []any{5}                 // slice/array element
_ = map[string]any{"k": 9}   // map value
_ = map[any]string{12: "x"}  // map key (and the matching m[12] lookup)
_ = holder{v: 3}             // keyed struct field
_ = any(7).(int)             // explicit conversion to any
fmt.Sprintf("%s.v%d.%d"u8, GOARCH, (nint)(8), i);
fmt.Sprintf("%s%c%03d"u8, d, (int32)(os.PathSeparator), seq);
v.Store((nint)(42));
any a = (nint)(7);
b = (nint)(8);
internal static any r() { return (nint)(42); }
ch.ᐸꟷ((nint)(3));
_ = new any[]{(nint)(5)}.slice();
_ = new map<@string, any>{["k"u8] = (nint)(9)};
_ = new map<any, @string>{[(nint)(12)] = "x"u8};   // and m[(nint)(12)]
_ = new holder(v: (nint)(3));
_ = ((any)(nint)(7))._<nint>();

untypedConstBoxCast (convCallExpr.go) drives the decision and returns the C# cast type (or none). The constant’s kind comes from info.Types[arg] — the type go/types has already DEFAULTED for the interface slot — so a literal (42), a unary (-5), a binary (1 + 2), and a named untyped const are all classified by one rule. Whether the rendering is a wrapper struct comes from exprRendersUntypedConstWrapper, which walks the expression for an *ast.Ident resolving (via Info.Uses) to a *types.Const whose OWN declared type is UntypedInt/UntypedRune/ UntypedFloatinfo.Types[arg] cannot answer this, since it reports plain int for a literal and a named untyped const alike. A defined-type-over-int constant (type MyInt int) is excluded (its box is the [GoType] wrapper, asserted as MyInt). Call arguments reuse the per-argument castArgToType plumbing; the other positions wrap through boxUntypedConstAsDefaultType.

Deliberate exclusions and known residues:

A variadic ...any slot used to be carved out for literals, on the theory that a boxed Int32 formats identically to nint under %d/%v so the cast was redundant noise on the most common call pattern. That is wrong wherever the boxed value is compared rather than printed: encoding/base32’s testEqual("Read after EOF, n = %d, expected %d", n, 0) boxed n as nint and 0 as Int32, AreEqual compared the dynamic types first, and the assert fired with a message that reads as an equality (n = 0, expected 0). It is also wrong for the %!-verb path, where the full-conversion fmt names the argument via reflect.TypeOf(arg).String() and a boxed Int32 reports "int32" instead of Go’s "int". The carve-out is gone; the corpus-wide footprint of removing it is two lines in two files (go/token/position.cs, internal/buildcfg/cfg.cs — Go’s own stdlib almost never passes a bare int literal into a ...any slot), plus two (int32)(os.PathSeparator) lines in testing/testing.cs from the rune-kind arm, which is a genuine %c fix for the Phase-4 test host.

The any map KEY used to be excluded too, because golib’s map uses the default Dictionary comparer (no numeric normalization — nint(6) != Int32(6)) and leaving both the composite key and a literal index uncast kept map[any]int{6:1}[6] round-tripping. That self-consistency only held for literal-vs-literal: a lookup by a real int VALUE (m[n], necessarily boxed nint — the only form Go can even distinguish) MISSED. convIndexExpr now applies the same cast to an untyped-constant index of an any-keyed map, so store and lookup agree on nint and both forms hit — while m[int32(6)] correctly misses, as Go requires.

(Guarded by the UntypedIntInterfaceBox behavioral test — each position read back through an x.(int) assertion or an int/int32 type switch, output-compared vs Go — and by AnyBoxedUntypedConst, which pins the whole default-type class in one program: variadic and non-variadic slots, named/literal/rune/float/beyond-int32 constants, []any/map[any]/map[K]any/ struct-field/chan-send/return/explicit-conversion positions, the map[any] store↔lookup round-trip plus its int32 miss, and the dynamic types a type switch reports — output-compared vs go run. The pre-fix converter diverges on 13 of its lines.)

An untyped STRING constant takes the same treatment, under the MIRROR-IMAGE shape rule (2026-07-25). Go’s default type for an untyped string constant is string — golib @string — and here it is the LITERAL that boxes wrong: convBasicLit renders a string literal as a plain C# "seed" (a System.String) or, where the position allows it, a "seed"u8 ReadOnlySpan<byte> (a ref struct, which cannot box at all — CS0029). A NAMED string constant needs nothing whether it is typed or untyped, because it is emitted as an @string member — there is no UntypedString wrapper struct — and its concatenations evaluate through @string’s own operators. So the string arm of untypedConstBoxCast keys off the literal-only SHAPE (constExprIsStringLiteralConcat: string BasicLits joined by + through parens), exactly INVERTING the exprRendersUntypedConstWrapper test the numeric arms apply.

The defect was that the coverage was partial and therefore self-inconsistent: the positions that already boxed through @string did so via a BasicLitContext flag (castToGoString, set by anyBoxedStringLitContext and its siblings, which also suppresses the u8 form) — struct field, slice element, map value, channel send, return, reassignment — while a call argument, a var-spec, an any-keyed map index lookup, and an explicit any("…") conversion left the literal bare. So box{v: "seed"} stored an @string while eq(b.v, "seed") passed a System.String, and Go’s true/true came back C# false/false — silently, with %T still printing string on both sides. A literal CONCATENATION ("se" + "ed") was uncovered at every position, because the flag only reaches a BasicLit, and where the u8 form survived (new box(v: "se"u8 + "ed"u8)) the emission did not even compile.

Both mechanisms are kept, each doing what it is good at. The literal context still produces the tighter (@string)"seed" at the positions that carry it, and the four missing positions were given it (convExprList via a new isStringBasicLit branch beside markAnyFieldLits’ identical either/or, visitValueSpec’s isAnyType context, and convIndexExpr’s any-key context). untypedConstBoxCast is the general net underneath — it catches the concatenations and any position that lacks the flag — and all its application sites now route through applyUntypedConstBoxCast, which skips a rendering that already leads with the cast so the two can never double up:

box{v: "seed"}; eq(b.v, "seed")   // Go: true      var v any = "x"      m[any] lookup by "seed"
new box(v: (@string)"seed");  eq(b.v, (@string)"seed");   // now true
any v = (@string)"x";         m[(@string)"seed"];
new any[]{(@string)("se" + "ed")};                        // the concat shape, previously bare

The cost is real and accepted, on the same reasoning that removed the variadic int carve-out: every string literal in a ...any slot now carries the cast, so fmt.Println("x") emits fmt.Println((@string)"x"). That is 866 lines across 150 behavioral projects — uniform, mechanical, and individually inspected. The alternative is a carve-out that leaves x.(string), case string:, ==, and %T silently wrong on exactly the values a Go program is most likely to compare.

(Guarded by the AnyBoxedUntypedConst extension — literal, concatenation, named-untyped and named-typed string constants at the variadic, non-variadic, var-spec, []any, map[any] key store and lookup, map[K]any value, keyed and positional struct-field, channel-send, return, explicit-conversion and type-assertion positions, plus the dynamic type a case string: switch reports — output-compared vs go run. The pre-fix converter leaves twelve of those renderings bare and emits four that do not compile at all — CS0029/CS0030/CS1503 on the "…"u8 span reaching an object slot.)

The same cast applies to an interface ==/!= comparison against an untyped int constant. Go compares an interface against a concrete value by its dynamic type and value, which the converter lowers to golib’s reflective AreEqual (convBinaryExpr’s interface-comparison branch — the iface == concrete / iface == iface / iface == ptr cases). AreEqual(object, object) bails early on leftType != right.GetType(), so a comparison operand’s boxed runtime type must match, exactly as a stored-then-asserted value’s does. A bare C# int literal boxes as System.Int32, so e.Value != 1 against an any field holding a boxed Go int (nint/IntPtr) — container/list’s TestIssue6349, emitted !AreEqual((~e).Value, 1) — saw IntPtr != Int32, reported the values UNEQUAL, and fired the test’s error even though the value round-tripped as 1. The interface-comparison branch now casts the concrete constant operand to its default type (!AreEqual((~e).Value, (nint)(1))), reusing the same untypedConstBoxCast predicate the boxing positions above key off — so the literal boxes as Go’s int dynamic type and the runtime-type guard passes. The cast is confined to the AreEqual lowering (interface-vs-concrete / interface / pointer), so a bare int compared against a concrete int — which lowers to C#’s native ==, never AreEqual — stays bare (no noise). The predicate yields nothing for the interface operand itself and for any non-constant operand, so only the genuine boxed-constant-mismatch site is touched. (Guarded by the InterfaceUntypedIntCompare behavioral test — an any-field-holding boxed int compared ==/!= against an int literal, negative-literal and literal-on-the-left forms, output-compared vs Go; the pre-fix converter emits the bare literal and mis-reports every comparison unequal.)

Multi-Assignment and Evaluation Order

All right-hand operands in assignment expressions in Go are evaluated before assignment to the left-hand operands. C# can operate equivalently using tuple deconstruction (thanks to Eugene Bekker for the suggestion). For the following Go code:

x, y = y, x+y

the equivalent C# code operates as follows:

(x, y) = (y, x + y);

The simultaneous deconstruction is mandatory whenever the targets alias — a swap s[i], s[j] = s[j], s[i] shattered into s[i] = s[j]; s[j] = s[i]; loses the first target’s original value (the second read sees the already-overwritten slot). The converter routes a multi-target assignment to the deconstruction form when every target is a reassignment to existing storage, counted per element; an index, star-deref, or selector LHS is always such a write. This recognition keyed off getIdentifier, which resolves a target’s root identifier by unwrapping index/star/selector/chan/array/map nodes but not ParenExpr — so an index whose base is a parenthesized pointer deref, (*p)[i] (the shape a pointer-receiver method uses to write its own named-slice element, e.g. a heap’s func (h *myHeap) Swap(i, j int) { (*h)[i], (*h)[j] = (*h)[j], (*h)[i] }), resolved to a nil root and was not counted as a reassignment. The parallel assignment then fell through to sequential statements and the swap silently corrupted the slice — one element lost, the other duplicated:

func (h *myHeap) Swap(i, j int) { (*h)[i], (*h)[j] = (*h)[j], (*h)[i] }
// before: two sequential stores drop the temporary — (h)[i] and (h)[j] both end up as the old (h)[j]
[GoRecv] internal static void Swap(this ref myHeap h, nint i, nint j) {
    ((h)[i], (h)[j]) = ((h)[j], (h)[i]);   // simultaneous deconstruction, correct swap
}

Such a paren-deref index LHS is now counted as a reassignment directly (a single-element (*h)[i] = v write emits identically on either path, so nothing else drifts). The bug was invisible to compilation — the broken form compiled cleanly — and only surfaced when a converted test ran: it silently miscompiled container/heap’s test heap and the internal/trace/internal/oldtrace order heap (three swap sites). (Guarded by the PointerReceiverSliceSwap behavioral test — a pointer-receiver swap and a full slice reversal by repeated swaps, output-compared vs go run; the pre-fix converter loses elements and diverges. It is also what makes container/heap’s Go test suite validate — see Phase 4.)

The swap recognition above routes a pure-reassignment parallel assignment (every target already exists) to the deconstruction form. A mixed parallel := — some targets reassigned, some newly declared, with rhsLen == lhsLen — was not covered: it satisfies neither lhsLen == reassignedCount nor the all-declared arm, and (unlike the call-deconstruction mixed cases below) has no single-call RHS to trigger tupleResult, so it fell through to sequential statements. When a reassigned target is read by a later right-hand expression, that read must see the target’s ORIGINAL value (Go evaluates every RHS before any store); sequential emission reads the already-updated value. strconv’s Ryū shortest-float rounding does exactly this — dc, fracc := dc>>extra, dc&extraMask, where fracc must read the pre-shift dc:

dc, fracc := dc>>extra, dc&extraMask   // fracc must read the ORIGINAL dc
(dc, var fracc) = (dc.Rsh(extra), (uint64)(dc & extraMask));   // whole tuple evaluated, then deconstructed

The converter now detects this read-after-write hazard (lhsReusedInLaterRhs: a written target’s types.Object appears in a strictly later RHS element) and routes the mixed assignment through the same deconstruction path, where C# evaluates the entire right-hand tuple before deconstructing. It is scoped to the actual hazard, so a hazard-free mixed := (m, n := m+1, 100) keeps its minimal sequential form. A newly-declared int/uint element takes its explicit type rather than var(a, b, nint c) = (b, a, a + b) — because var would infer C# 32-bit int from a literal/int32 RHS instead of the Go-int-target nint; string (an @string’s u8 span cannot sit on a value-tuple LHS) and unsafe.Pointer hazards are excluded and keep the sequential form, a documented limitation with no stdlib occurrence. Like the swap bug this was invisible to compilation and surfaced only at runtime: it made strconv’s shortest-float formatting round down, failing math’s TestFloatMinMax (4e-324 vs Go’s 5e-324). (Guarded by the ParallelAssignmentHazard behavioral test — reassigned + newly-declared parallel forms whose later RHS re-reads a written target, output-compared vs go run; the pre-fix converter diverges. It is also what makes math’s Go test suite validate — see Phase 4.)

Go’s partial redeclarationa, b := f() where a already exists in the same scope — reuses a (assigns it) and declares only the new names. A blanket var (a, b) would re-declare the reused variable, so the converter emits var per newly-declared element only:

frac, e := normalize(frac)   // frac is the existing parameter; e is new
(frac, var e) = normalize(frac);

The same per-element mechanism handles a destructured element whose address is taken (list, delta := netpoll(0); injectglist(&list)). Such a local must be heap-boxed so its Ꮡlist companion exists, but the combined var (list, delta) = … deconstruction cannot declare it as a ref var … = ref heap(…). The converter emits the escaping element’s heap declaration first, then a mixed deconstruction-assignment in which the escaping element is the pre-declared box ref-local and the rest declare with var:

list, delta := netpoll(0)
injectglist(&list)
ref var list = ref heap<gList>(out var list);
(list, var delta) = netpoll(0);     // list is the box ref-local; delta is newly declared
injectglist(list);                 // Ꮡlist now exists

Without this, &list emits Ꮡlist with no box (CS0103), and the Ꮡ(value) copy fallback would silently lose writes made through the pointer. (Guarded by the TupleDestructureEscapingLocal behavioral test — a mutate-through-pointer proves the real local is updated; runtime exercises it in the netpoll poll loops.)

A subtler case: a newly-declared tuple element can be flagged escaping by analysis yet need no heap box — typically an already-pointer local that is merely returned (pp, now := pidleget(now), where pp is a *p that the function returns). The heap-decl path above only owns elements that produce an actual ref var … = ref heap(…); an escaping element with no such declaration must still be counted as newly-declared so it receives its var, or the deconstruction emits (pp, now) = … with pp declared nowhere (CS0103). Both the mixed ((var pp, now) = …, reusing the value parameter now) and the all-shadowing (var (ppΔ1, gpΔ1) = …) forms are handled. (Guarded by the TupleMixedDeclareReassign behavioral test; runtime hits it in pidlegetSpinning and findRunnable.)

A tuple deconstruction into INTERFACE variables hoists the call when a component needs converting. Reassigning a multi-value call into pre-declared interface locals (c, err = sd.dialTCP(…) with var c Conn) can require a per-component interface conversion C#’s tuple assignment cannot perform implicitly — a ж<TCPConn> component satisfies Conn only through its generated pointer adapter, an explicit conversion (CS0266 ×11 in net’s dial.go). Mirroring the return-statement tuple arm, the call is hoisted into temp markers and each component converts in a tuple literal:

var (1, 2) = sd.dialTCP(ctx, laΔ1, raΔ1);
(c, err) = (new TCPConnжConn(1), 2);

The arm fires only for a statement-position deconstruction (one call RHS, several LHS) where some non-empty-interface target’s tuple component is a non-identical, non-interface type; all other deconstructions keep the direct form. (Guarded by the InterfaceCasting extension makeCounter — a (*Counter, error) call deconstructed into an Incrementer — runtime-verified against Go.)

A SELECTOR left-hand side counts as a reassignment — a field swap must stay simultaneous

The paren-deref fix above closed the index form of the target-classification gap; the selector form (x.f, y.g = …) had the same hole. The classifier deliberately drops a selector LHS’s root identifier (getIdentifier would return the base — a package name, or a struct local — which, not itself being reassigned, would wrongly be counted as a new declaration and take a var prefix), and then counted it as neither reassigned nor declared. A parallel assignment whose targets are all selectors therefore satisfied no tuple-path gate — not lhsLen == reassignedCount, not lhsLen == declaredCount, and (with no single-call RHS) not tupleResult — and shattered into sequential stores, losing the swap’s implicit temporary:

// regexp/onepass.go — makeOnePass: put the empty-match leg in inst.Out
inst.Out, inst.Arg = inst.Arg, inst.Out
// before — both fields end up holding the ORIGINAL Arg
inst.Value.Out = inst.Value.Arg;
inst.Value.Arg = inst.Value.Out;

// after — the simultaneous deconstruction C# gives for free
(inst.Value.Out, inst.Value.Arg) = (inst.Value.Arg, inst.Value.Out);

Note the tell in the “before”: the very next statement of the same Go function swaps two plain locals (matchOut, matchArg = matchArg, matchOut) and was already emitted correctly as (matchOut, matchArg) = (matchArg, matchOut); — the divergence was purely the target shape. The consequence was silent: every InstAlt whose empty-match leg needed swapping got a corrupted dispatch, so regexp quietly lost its one-pass engine for ^[a-c]*$, ^(?:a*)$, ^.bc(d|e)*$ and friends, and ^[a-c]+$ stopped matching "abc" at all. A field write is a write to existing storage exactly as the index and star-deref forms are, so it is now counted as reassigned like them — which also aligns single-selector assignments (h.flags &= ^writing) with the narrowing-cast rendering a plain-ident target already got. (Guarded by the ParallelAssignmentHazard extension — a pointer-receiver field swap, a cross-struct rotate reading pre-assignment values, and a package-var swap; and by the RangeVarReassign / AndNotAssignNarrow goldens, whose re-baselines are this routing change.)

An address-taken reference-typed local heap-boxes too — Ꮡ(value) copies are only for reads

An INHERENTLY heap-allocated local (interface/pointer/slice/map/chan/func) is already a reference, so escape analysis blanket-marks it and the box machinery historically skipped it — &local fell back to the Ꮡ(value) copy constructor. That is only sound when nothing writes through the pointer: dwarf’s zeroArray(&typ) (with typ Type, an interface local) writes *t = &tt in the callee, and the copy-box silently dropped the write (C# printed the un-replaced value — a behavioral divergence, not a compile error). The box predicate (identHasHeapBox) now boxes such a local when its address is genuinely taken — by a capturing closure (the pre-existing box-ref-var case) or anywhere in the current function (memoized &ident scan) — so &swapped references a real aliasing box:

var swapped Animal = Dog{}
replaceAnimal(&swapped)      // callee: *a = &Cat{}
ref var swapped = ref heap<Animal>(out var swapped);
swapped = new Dog(nil);
replaceAnimal(swapped);     // callee writes through the SAME box — "Meow!"

Details: the box declaration always uses the parameterless heap<T>(out …) form for these (new Animal() on an interface is CS0144, and the reference-like zero value is exactly what the box provides); a []T slice local routes to heap<slice<T>> (the array-branch prefix test mistook [] for an array and emitted a mismatching heap<array<T>>); and the pointer-form ident render in convIdent deliberately keeps the PLAIN value render for these locals (new Middle(Inner: inner) wants the held pointer; only an explicit &inner wants the ж<ж<T>> box, via convUnaryExpr). Non-escaping and never-addressed reference locals are unchanged (no churn). (Guarded by InterfaceCasting’s replaceAnimal — the swap is visible through the original variable; the churned goldens PointerToPointer, UnsafePointerReinterpret, DerefPointerToField, PointerCastSliceRange, EscapedLoopVarSiblingIndex all re-verified against Go.)

An address-taken NAMED RESULT heap-boxes too

A named result is declared in the function signature, not by any body statement, so the escape analysis’ define-walk never reached it — it was analyzed only when it also happened to sit on a := LHS somewhere. A named result whose address is taken but which is never :=-reassigned (text/tabwriter’s func (b *Writer) flush() (err error) { defer b.handlePanic(&err, "Flush"); … }, where the deferred handler writes *err = nerr.err) was therefore left unboxed: &err fell back to the Ꮡ(err) copy box, so the handler wrote a copy while return err read the original — silently dropping the error (Go promotes an address-taken named result to the heap). The escape analysis now walks every named result and marks it escaping when its address is genuinely taken (&err, &err.field, &err[i]), so the existing heap-box machinery boxes it at entry — the box Ꮡerr, the deferred handler’s write through the pointer, and the final return err all reference one slot:

func (b *Writer) flush() (err error) { defer b.handlePanic(&err, "Flush");  }   // handlePanic: *err = e
internal static error /*err*/ flush(this ж<Writer> b) {
    heap<error>(out var err);                            // box declared before the try
    GoFrame  = default;
    try {
        ref var b = ref b.DerefOrNull();

        ref var err = ref err.ValueSlot;                 // value alias inside
        defer(b.handlePanic, err, flushˢ, ref );       // the BOX is passed, not Ꮡ(copy)
        b.flushNoDefers();
        err = default!;
    }
    catch (Exception ex) when (GoFrame.IsPanic(ex, out PanicException? p)) { GoFrame.Capture(p); }
    finally { .Run(); }
    return err.ValueSlot;                                // reads the SAME slot the handler wrote
}

The trigger is address-taken specifically — a named result merely referenced or written inside a closure is not boxed (a C# closure already captures the outer local by reference), so a common defer func(){ err = wrap(err) }() result keeps its plain declaration (no churn). The verdict is only ever SET true, so a result whose address is never taken is byte-unchanged. Function literals take the same treatment. (Guarded by NamedResultAddressEscape — an error result written through &err by a deferred handler and a value int result mutated through &n, output-compared vs Go; PointerToInterfaceParamDeref re-baselined to the box form, its output unchanged since its handler only reads *err.)

An address-taken VALUE PARAMETER heap-boxes too

A value parameter, like a named result, is declared in the signature — not by any body statement — so the escape analysis’ define-walk never reached it either. Parameters were additionally kept out of the full escape analysis on purpose, which left exactly one parameter trigger in the pass: the capture-mode-method check (markCaptureModeBoxedParams, A capture-mode method called on a value PARAMETER below). A plain &param was not a trigger, so &r fell back to the call-site Ꮡ(r) copy box — it compiles, and silently drops every write the callee makes through the pointer:

func DrawMask(dst Image, r image.Rectangle, src Image, sp image.Point, ) {   // image/draw
	clip(dst, &r, src, &sp, mask, &mp)   // clip narrows r (and sp/mp) IN PLACE
	if r.Empty() { return }              // …but the narrowed r was never seen

The escape pass now marks a value parameter whose address is genuinely taken — &r, &r.field… (a value-field chain rooted at the parameter), or &r[i] — via objectAddressTaken, the same generic per-object scan the named-result arm above uses (renamed from namedResultAddressTaken now that it serves both signature-declared categories). The existing entry-time box machinery then boxes the parameter at entry, exactly as the capture-mode trigger does:

func clipParam(r Rect) Rect { clip(&r, 5, 5); return r }
internal static Rect clipParam(Rect rʗp) {
    ref var r = ref heap(rʗp, out var r);   // ENTRY-time box, never a call-site copy
    clip(r, 5, 5);                          // the callee writes THIS storage…
    return r;                                // …and the body reads it back
}

Entry-time boxing is what preserves Go’s semantics on both sides: the callee’s writes are visible to the rest of the body, and the caller’s argument stays untouched (the parameter is still by-value — the box is initialized from the incoming ʗp copy). The &param.field form renders through the box accessor (Ꮡb.of(Box.ᏑR).of(Rect.ᏑMin)) and &param[i] through Ꮡa.at<E>(i) — the latter now superseding, at its parameter sites, the array-parameter fallback that copy-boxed the array<T> wrapper (Ꮡ(value).at<byte>(0), kept for any array base that still owns no box) and was correct only because the wrapper shares its T[]. An ARRAY param folds its Go by-value clone into the box init: ref var a = ref heap(aʗp.Clone(), out var Ꮡa);.

An INHERENTLY-HEAP parameter takes only the BARE &p form (paramAddressTakenNeedsBox). A slice/map/chan/interface/func — and a type parameter, whose underlying is its constraint interface — is already a reference, so only the address of the reference variable itself needs a box; &p[i] addresses the shared backing array, which the emitted element form Ꮡ(p, i) already aliases correctly, and Go likewise does not heap-promote a slice header for an element address. This mirrors identHasHeapBox’s own box gate, which is what makes the restriction load-bearing rather than an optimization: marking a verdict that gate then refuses would leave identEscapesHeap set with no box, and that map is read raw by the capture analysis and several emitters. Measured on the first cut (which did not restrict), 48 of 149 newly-boxed parameters were &s[i]-only slices — including unicode.is16/is32, slices.Equal/Index, subtle.XORBytes, crypto/internal/alias, and the Windows syscall buffer paths — every one allocating a box per call for no semantic gain.

The analysis trigger and the emission gate must move together. markCaptureModeBoxedParams records the reason and paramBoxReasonHolds (read by visitFuncDecl’s parameter preamble, its processPotentialCapture box-ref arm, and convFuncLit’s literal prologue) re-verifies it against the declaring ident; a reason recorded by analysis but missing from the gate leaves body uses referencing a box that was never declared (CS0103), and the reverse declares a box nothing references. Both gained the address-taken trigger in the same change. The gate stays narrow in the other direction: a param that leaks into identEscapesHeap some other way — a mixed data, pc, line := … define re-uses the param object, so the define walker escape-analyzes it (debug/gosym’s slice) — still keeps its historical unboxed emission. Function-literal params take the identical treatment (funcLitHeapBoxParamIdents), and a boxed param referenced from a nested closure is box-ref’d rather than snapshot-copied (see CaptureModeParamClosure below), so the closure, the body, and the callee all share the one parameter variable Go gives them.

This was the fifth path in the Ꮡ(value) copy-box family, after the pointer-to-array element, the slice/array field of a receiver, the field-addressed value local, and the named result — and the value RECEIVER, below, is the sixth and last. The corpus consumers the parameter arm corrects are all silent-wrong-answer bugs, not compile errors:

Package Site Before → after
crypto/x509 parser.cs parseValidity(der cryptobyte.String) calls parseTime(&der) twice two independent Ꮡ(der) copies → one Ꮡder: the DER cursor now advances, so notAfter is parsed from the bytes after notBefore instead of re-parsing the same ones. parseName, forEachSAN, parseBasicConstraintsExtension, parseExtKeyUsageExtension and parseCertificatePoliciesExtension have the same non-advancing-cursor shape (der.ReadASN1(&der, …)).
crypto/x509 verify.cs Verify(opts VerifyOptions)systemVerify(&opts), isValid(…, &opts), buildChains(…, &opts) three separate copies → one shared Ꮡopts.
net/http server.cs Serve(l net.Listener) registers trackListener(&l, true) and defers trackListener(&l, false) the register and deregister boxed different copies, so the deregister’s delete(srv.listeners, ln) could never match the registered key — every served listener leaked. Now both pass Ꮡl.
database/sql sql.cs (*Rows).close(err error)fn(rs, &err) plus a withLock closure that assigns err the hook’s *err = … wrote a copy while return err read the original. Now the closure (Ꮡerr.ValueSlot = …), the hook, and the return share one slot.
testing/slogtest slogtest.cs wrapper.Handle(…, r slog.Record)h.mod(&r) then h.Handler.Handle(ctx, r) mod mutated a copy, so the wrapped handler received the unmodified record — the whole wrapper mechanism was a no-op.

(Guarded by the AddressOfParamWrite behavioral test — a value parameter clipped in place through &r by a callee that writes, a &param.field bump, an &param[i] bump on an array parameter, and three controls that must not change: a read-only &param, an address-taken local, and a parameter whose address is never taken; the caller’s own argument is printed after the call to prove it stayed untouched. Output-compared vs go run — under the pre-fix emission the three write cases all printed the unmodified input.)

The value RECEIVER is the same category — and it closes the family. A method’s receiver is the third thing declared in a signature rather than by a body statement, so it failed for exactly the reason the named result and the value parameter did: it arrives on funcDecl.Recv, which neither the define-walk nor markCaptureModeBoxedParams (which walks funcType.Params) ever reaches. Two distinct symptoms, both closed by markAddressTakenBoxedReceiver:

Go (receiver starts Box{Rect{0,16}, 6} / Trio{7,8,9}) Pre-fix C# Go says Pre-fix C# said
func (b Box) Bumped() int { bumpBox(&b); return b.Tag } bumpBox(Ꮡ(b)); 16 6
func (b Box) Clipped() … { clip(&b.R, 5, 5) … } clip(Ꮡ(b).of(Box.ᏑR), 5, 5); 5 5 0 16
func (a Trio) Elem() … { bump(&a[1]) … } (array receiver) bump(Ꮡa.at<nint>(1)); 7 18 9 CS0103Ꮡa never declared

The array-receiver row is not a silent wrong answer but a hard compile error: convUnaryExpr’s array-base copy-box fallback (Ꮡ(value).at<E>(i), kept for an array base that owns no box) is keyed on identIsParameter, and the receiver is deliberately not a parameter in that model — so the naive identity-box form was emitted for a box nothing declared. Giving the receiver a real box fixes both symptoms with one mechanism.

The convention is the parameter’s, reused verbatim rather than invented: the incoming value takes the ʗp name and the entry preamble re-declares the Go name as the boxed ref alias, with an ARRAY receiver folding its Go by-value clone into the box init exactly as an array parameter does.

func (b Box) Bumped() int { bumpBox(&b); return b.Tag }
func (a Trio) Elem() int  { bump(&a[1]); return a[1] }
public static nint Bumped(this Box bʗp) {
    ref var b = ref heap(bʗp, out var b);
    bumpBox(b);
    return b.Tag;
}
public static nint Elem(this Trio aʗp) {
    ref var a = ref heap(aʗp.Clone(), out var a);   // the by-value clone folds into the box init
    bump(a.at<nint>(1));
    return a[1];
}

The public surface is unchanged, which is what makes the rename safe: only the receiver’s name moves, never its C# type, so the method stays the value-receiver extension Go’s method set requires — still callable on a value, still callable through a pointer (which copies into the receiver, so the caller’s own variable is untouched, matching Go), and still satisfying an interface it implements by value. RecvGenerator is unaffected because it is gated on IsRefRecv (this ref T), and a value receiver never carries ref; [GoRecv] is likewise emitted only for a this ref signature. The box is an implementation detail of the body, exactly as the parameter ʗp + heap preamble is.

Analysis and emission move together here too — markAddressTakenBoxedReceiver records and recvBoxReasonHolds (read by paramNeedsHeapBox, which now consults funcDecl.Recv before the params walk) re-verifies. The receiver’s reason set is deliberately narrower than a parameter’s: just the address-taken predicate. A capture-mode (direct-ж) receiver is already served by packageDirectBoxReceiverMethods, which emits the box as the receiver instead of renaming it, and a receiver the capture analysis routed to box-ref storage must never take the ʗp form at all — so neither bodyCallsCaptureModeMethodOn nor isLambdaBoxRefVar joins the receiver gate.

The receiver reuses paramAddressTakenNeedsBox, so an inherently-heap receiver still boxes only for the bare &r. Measured, that restriction rejects zero corpus sites today — unlike the parameter arm’s 48 of 149 — and the reason is worth recording: the parameter over-boxings came from the first cut also recording packageCaptureModeBoxIdents, which forces identHasHeapBox to grant a box. The receiver arm never records it, so for a &r[i]-only slice receiver identHasHeapBox’s own gate refuses the box independently and the emission is byte-identical either way. The restriction is kept because it keeps the analysis verdict and that gate in agreement: marking a verdict the gate then refuses leaves identEscapesHeap set with no box, and that map is read raw by the capture analysis and several emitters.

Corpus footprint of the fix, from a two-seeded-root A/B (master converter vs fixed, both reconverting all 305 projects into their own temp root): 3 receiver sites across 2 filesencoding/base64’s WithPadding and Strict and encoding/base32’s WithPadding, each func (enc Encoding) … *Encoding returning &enc. All three are correct-by-luck under the old emission: &enc is the last operation, after every mutation, so the Ꮡ(enc) copy carried the mutated value out. They are now one storage identity rather than two, at the same single allocation. That there is no live victim is the point — this path was closed at its root rather than after a sixth package was found broken by it.

The receiver decision is MODE-STABLE, structurally-stdlib and -tests cannot disagree about it, and that is worth stating because the sibling category can. A package-level var’s address may be taken by the package’s own _test.go, which a production go/packages load never sees, so the storage shape has to be reconciled deliberately (A global addressed only by the package’s own _test.go is still heap-boxed, below). A receiver is function-scoped: its address can only be taken inside its own method body, and a production method’s body is production source that no _test.go can add a statement to. markAddressTakenBoxedReceiver reads funcDecl.Recv against funcDecl.Body and nothing else, so admitting test files to the analysis universe cannot change its answer. Measured against the claim rather than assumed: a whole-stdlib -stdlib reconvert and the -tests pipeline’s regenerated production .cs for encoding/base32 and encoding/base64 are byte-identical. (A board row read the opposite from a go2cs.exe built before this fix, and filed the resulting sweep drift as an open mode-instability that must never be banked; the retraction — and the bank — are in phase4/BOARD-next-validation-candidates.md.)

(Guarded by the same AddressOfParamWrite behavioral test, extended: a value receiver bumped in place through &b, a &recv.field clip, a &recv[i] bump on an ARRAY receiver, and four controls that must not change — a &recv[i] on an inherently-heap slice receiver, a read-only &recv, a pointer receiver, and a receiver whose address is never taken — plus the two public-surface cases, the boxed value-receiver method called through a pointer and through an interface. Output-compared vs go run.)

A field-addressed value local heap-boxes — Ꮡ(x).of(…) copy-boxes orphan writes

Escape analysis’s address-of walk marked &x (direct) and &x[k] (element) but had no selector arm, so a value-struct local whose FIELD address was taken in plain assignment (or composite-literal / return) position stayed unboxed, and convUnaryExpr fell back to the Ꮡ(x).of(T.Ꮡval) copy-box — writes through the pointer landed in the copy and were silently lost (Go reads the write back through x; C# printed the original value — a behavioral divergence, not a compile error). The walk now peels a value-field selector chain (x.f1.…fn, every hop a direct FieldVal selection with no pointer indirection) to its root ident and marks the root escaping, so the emission routes through the identity box:

x := Thing{val: 7}
p := &x.val
*p = 99
return x.val                       // Go: 99
ref var x = ref heap<Thing>(out var x);
x = new Thing(val: 7);
var p = x.of(Thing.val);
p.Value = 99;
return x.val;                      // 99 — the pointer aliases x's box

Multi-hop chains chain the accessors (&w.inner.valᏑw.of(Wrap.Ꮡinner).of(Thing.Ꮡval)), and a field promoted through a VALUE embed roots at the local too (&o.evᏑo.of(Outer.Ꮡev)). A hop that crosses a POINTER — an explicit w.ptr.val deref or a field promoted through an embedded pointer (both are Selection.Indirect()) — aliases the POINTEE’s storage instead, so the root deliberately stays unboxed: w.ptr.of(Thing.Ꮡval) already writes through the held box. (Guarded by LocalStructFieldAddr — plain, nested, method-body, value-embed-promoted, composite-literal, and return positions plus the pointer-hop negative control, all output-compared vs Go; the one churned golden UnsafePointerParamPin&h.v under unsafe.Pointer — re-verified.)

A PACKAGE-LEVEL function literal’s own locals are analyzed too

Every heap-box rule above is decided by the escape-analysis pass, and that pass reached a variable only through its declaring function declaration: the driver walked *ast.FuncDecl bodies and ran the define-walk (:=, var, range/for/if/switch/type-switch init defines) against each body. A function literal that is not inside any declaration — a package-level var initializer — hit a separate arm that marked its parameters and named results but never ran that walk, so none of its own locals were ever analyzed and every one of them stayed unboxed, whatever the body did with it.

That is the shape of every Go test table (var tests = []struct{ name string; f func() }{{"…", func(){ … }}}) and of the sync.OnceFunc/OnceValue package-level initializers, and it produced both failure modes at once:

var InitWSA = sync.OnceFunc(func() {          // internal/poll fd_windows.go
    var d syscall.WSAData
    e := syscall.WSAStartup(uint32(0x202), &d)   // fills d
    
})
// before — Ꮡ(d) COPY-boxes: WSAStartup filled a copy and the Winsock data was lost (silent)
Δsyscall.WSAData d = new();
var e = Δsyscall.WSAStartup((uint32)0x202, (d));
// after — the identity box, exactly as an in-declaration local has always emitted
ref var d = ref heap(new Δsyscall.WSAData(), out var d);
var e = Δsyscall.WSAStartup((uint32)0x202, d);

The compile-visible half is a capture-mode receiver: sync mutex_test.go’s misuseTests table does var mu sync.Mutex; mu.Unlock() inside such a literal, and an unboxed mu emits the VALUE receiver form, which binds no ж<Mutex> extension overload at all (CS1929 ×16). The define-walk is now a shared helper both arms call, so a package-level literal gets byte-identical treatment to a declaration body. Literals nested inside a declaration were already walked against the enclosing body (a superset), and the analysis short-circuits per object, so the overlap is a no-op — the behavioral corpus is byte-identical and the full-stdlib footprint is exactly the three package-level initializers that needed it (internal/poll, internal/syscall/windows, internal/sysinfo). (Guarded by PkgLevelFuncLitLocals — every declaration form inside a package-level literal (var, :=, for/if/switch init, range value, a nested closure) plus a standalone package-level literal and an in-declaration control, output-compared vs Go.)

A pointer-receiver METHOD VALUE heap-boxes its receiver — the implicit (&x).M

Go’s spec makes c.split shorthand for (&c).split when c is addressable and split has a pointer receiver: the method value binds a pointer into c’s own storage, so every write it makes through its receiver is visible in c afterwards. That is the same escape condition as an explicit &c — just written without the &, which is precisely why the address-of walk above could not see it. The local stayed unpromoted and emission fell back to the copy box Ꮡ(c).split: it compiled and ran, but the method mutated a copy and the caller’s writes were silently dropped. bufio’s s.Split(c.split) is the real site — the scan counter never decremented, so the reader “stopped with 10000 left to process”.

Escape analysis now recognizes a method value that selects a pointer-receiver method on the local’s own storage — the bare ident, or a non-indirect value-field chain rooted at it, reusing the same root walk the explicit-& arm uses — and marks it escaping, so the emission becomes the aliasing box:

c := counter{n: 100}
sum := applyInt(c.dec, 5, 7)       // (&c).dec — Go: c.n is 88 afterwards
ref var c = ref heap<counter>(out var c);
c = new counter(n: 100);
nint sum = applyInt(c.dec, 5, 7);   // Ꮡc aliases c — was Ꮡ(c), a copy

The rule is position-general (argument, assignment, composite element, return) and covers value parameters and named results as well as locals: a parameter takes the entry-time box (ref var c = ref heap(cʗp, out var Ꮡc);) rather than a call-site copy, exactly as the capture-mode call form already did — the three emitters that materialize a parameter box now share one predicate (paramBoxReasonHolds), so an analysis reason can no longer be recorded without its box being declared (CS0103). An inherently-heap named slice/map/chan receiver takes the box too (Ꮡ(l) would clone the slice header, orphaning *l = append(*l, v)).

Three boundaries stay deliberately outside the rule. A direct call c.dec() is not a method value: it binds C#’s this ref counter c extension receiver against the variable and is already correct, and promoting for it would heap-box every local that calls a pointer-receiver method. A pointer-typed base (p.M where p is *counter) passes the pointer value and takes no address of p. A value-receiver method value (c.peek) copies the receiver at evaluation time in Go — which is what the existing lambda-snapshot emission (var cʗ1 = c; … cʗ1.peek()) already does. (Guarded by MethodValueReceiverEscape — local, value parameter, named result, value-field chain, named-slice, and closure-formed method values, plus all three negative controls, output-compared vs Go. Note the still-open residue: a method value bound to a variablef := c.dec — takes the lambda-snapshot path, which copies the receiver even when the local is promoted.)

A blank-identifier element in a split multi-assign is a C# discard, never a declaration. Go’s _, _, _, _ = a, b, c, d (a common “mark these used” idiom) is emitted as one bare discard per element with no var — the per-element discard test keys off each LHS ident, not just the single-LHS case, so every blank stays a discard:

_, _, _, _ = fi, fn, gi, gn
_ = fi;
_ = fn;
_ = gi;
_ = gn;

A blanket var _ on each would declare _ once and then collide on every later element (CS0128 “a local named _ is already defined”). (Guarded by the BlankIdentifierCollision behavioral test; runtime hits it in softfloat64’s fdiv64.)

Short Variable Redeclaration (Shadowing)

When using Go’s short variable declaration syntax, e.g., x := 2, a variable can be redeclared in a lesser (nested) scope. The inner declaration “shadows” the outer one: the inner instance is manipulated while the outer value is preserved, and once the inner scope ends the outer variable still holds its original value.

C# forbids a local (or a lambda parameter) from shadowing an enclosing local of the same name (CS0136). So rather than the older save/restore approach, the converter renames the shadowing inner variable with a Δ disambiguation suffix (xxΔ1, xΔ2, …) and rewrites all references within that scope to the renamed identifier. The outer variable is untouched, so its value is naturally preserved. For example:

func sumWithLenLocal(buf []int) int {
    total := 0
    len := len(buf)       // a local named like the built-in, shadowing it
    for i := 0; i < len; i++ {
        total += i
    }
    return total + len
}

converts to:

internal static nint sumWithLenLocal(slice<nint> buf) {
    nint total = 0;
    nint lenΔ1 = len(buf);          // renamed; the built-in call stays `len(...)`
    for (nint i = 0; i < lenΔ1; i++) {
        total += i;
    }
    return total + lenΔ1;
}

The same Δ mechanism handles a local shadowing a called built-in (as above), a nested-block variable shadowing a function-level one, an IIFE/closure parameter colliding with an outer local, and a type-switch guard (switch x := x.(type)) whose variable shadows an enclosing one — the guard is renamed within the switch (case T xΔ1:) while references after the switch still resolve to the enclosing variable, matching Go’s scoping.

The nested-block detection holds across a closed sibling block: a declaration that follows a nested block inside the same enclosing block (runtime procresize’s second trace := traceAcquire() after an inner if {…} that declared its own trace) is still checked against enclosing scopes. The shadow tracker’s processing flag is shared across the nesting levels of a block tracker, so an inner block’s cleanup must restore it for the still-open enclosing block rather than clear it — clearing it made the follow-on declaration skip the check and collide with the function-level local (both emitted Δtrace — the LAST runtime compile error, CS0136). Composition with the collision rename is suffix-based: the function-level local keeps the base name (Δtrace after its collision prefix), the shadows number independently of the prefix (traceΔ1, traceΔ2 — a shadow name no longer collides, so it takes no Δ prefix). (Guarded by the GlobalShadowedByLocal extension nestedBlockShadow — the three bindings verified by value vs Go.)

A local shadowing a same-named package function it calls in its own initializersigname := signame(gp.sig) (runtime panic.go) — renames the same way. Go starts the shadow after the initializer, so the call resolves to the function; C# scopes the local over its own initializer, so an unrenamed call would bind the (non-invocable) string local (CS0149). Detection is object-accurate: any identifier go/types resolves to the function while a same-named local exists means Go bound it where the local was not yet in scope — the old position guard (“call before the declaration”) excluded exactly the own-initializer case. (Guarded by the BuiltinShadowLocal extension — a package signame shadowed in its own initializer, values vs Go.)

A block-scoped const that shadows an enclosing parameter or variable is renamed the same way. func f(ns int64) { …; const ns = 10e6; use(ns) } (runtime notetsleep_internal) is legal in Go but the inner const ns and the param ns both emit as ns in C# (CS0136). A const is tracked separately from variables — its go/types object is a *types.Const, not the *types.Var the scope stack records — so the shadow-rename pass had ignored it; it now records a shadowing const (detected by the same by-name enclosing-scope check) and rewrites its declaration and every use to nsΔ1, leaving the enclosing ns untouched. Only a shadowing const is renamed (a plain block const keeps its name, no churn). (Guarded by the ConstShadowsParam behavioral test — the inner uses bind the const value, the outer uses bind the param.)

Renaming depends on correctly identifying which declarations are function-level — the set a nested variable of the same name must avoid (C# forbids the nested one even when the function-level one is declared later). A for init; … loop’s := variable, and a range := key/value, are scoped to their own statement, not the function body, so they are deliberately excluded from that set. Recording a for-loop variable as function-level (it is encountered first, in source order) would mask the real function-level variable of the same name declared afterward — for b := …{} for b := …{} … b := newBucket(…) — leaving all three emitted as b and colliding (CS0136). With the for-loop variables correctly treated as inner scopes, they are renamed bΔ1/bΔ2 while the function-level b keeps its name. (Guarded by the ForVarMasksFuncLevel behavioral test; runtime hit this in stkbucket.)

The same forward-collision rule applies at every block level, not just the function body. C# CS0136 fires whenever a name is declared in two scopes where one encloses the other, regardless of declaration order — so a nested variable must be renamed if the same name is declared anywhere in an enclosing block, whether that declaration appears before or after it in source. The scope-stack walk only records declarations already seen (backward), and the function-level forward set covers only the function body; a variable declared later in an intermediate enclosing block would otherwise be missed. To close that gap, each block scope (function body, if/for/range/switch/select bodies, bare blocks, and case/comm-clause bodies) is pre-scanned for its directly-declared names (:= and var, excluding a control statement’s own init :=, which is scoped to that statement) when the scope is pushed, so forward declarations are visible to the shadow check. For example, the runtime’s runGCProg has two for off := … loops followed by off := n - nbits in the same enclosing for {} body — the block-level off encloses both loops, so the loop variables are renamed offΔ1/offΔ2 while the block-level off keeps its name. (Guarded by the ForVarMasksBlockLevel behavioral test — distinct from ForVarMasksFuncLevel, where the later same-named variable is function-level; this cleared 5 runtime CS0136 in runGCProg/mprof/runtime1/time.)

The mirror image — a local shadowing a package-level GLOBAL — is resolved the other way: the global reference is qualified rather than the local renamed. C# locals are function-scoped, so a local trace := traceAcquire() shadows a same-named global var trace throughout the function, and an earlier read of the global binds to the not-yet-declared local (CS0841; the wrong variable regardless). Renaming the local is the fragile, entangled path (it interacts with collision renames and the shadow-rename counter); instead a use whose ident resolves to a package-level var of this package — while a same-named function-level local is declared — is emitted qualified with the package static class: runtime_package.Δtrace.minPageHeapAddr, which a local can never shadow. This is the same package-class qualifier the box-field accessor uses for a shadowed owning type (below). Runtime’s traceallocfree.traceSnapshotMemory reads the global trace.minPageHeapAddr before its local trace := traceAcquire() (both collision-renamed Δtrace); the qualifier is gated so an ordinary global (no shadowing local) and the local’s own uses (which resolve to the local, not the package scope) keep their bare, Go-like form — no churn. (Guarded by the GlobalShadowedByLocal behavioral test — a collision-renamed global and a plain global each read before a same-named local; cleared runtime’s last CS0841.)

That qualifier names the class that DECLARES the global, which under the white-box test model is not always the production one. In a whitebox-reference internal variant, the emission unit is the bridge class (md5_internal_test_package), and a package-level declaration contributed by an internal _test.go lives there — the production class holds only the production declarations. Qualifying unconditionally with <pkg>_package therefore names nothing whenever the shadowed global comes from a test file (CS0117): crypto/md5’s benchmarkSize opens with the idiomatic buf := buf, whose package-level buf is declared in md5_test.go, and it emitted md5_package.buf. The qualifier is now chosen by where the object is declared — the bridge class for a _test.go declaration, the production class otherwise — so both halves of a white-box compilation stay addressable from the same function. Production emission is untouched (the override is empty outside the test variant), so this is zero-churn for the corpus; crypto/md5’s banked suite is its operational guard.

Two subtleties complete this for loops whose variable’s box hoists before the loop. A hoisted loop-variable box is block-scoped in C#, one per name per container. A loop variable that escapes to the heap and whose box is emitted before the loop — today that is a string/int/chan/func range variable, or the legacy fallback of a for i := … clause variable referenced by a clause func literal; every other case boxes per-iteration inside the body (slice/array/map ranges via the deferred range-var box, and for clause variables via the per-iteration carrier rewrite — Go 1.22 semantics, see Labeled Control Flow and Loop Variables) — is emitted as a ref var i = ref heap<…>(out var Ꮡi) declaration hoisted into the enclosing container (function body, block, or switch/select clause) — see Pointers — so other loops in that container that reuse i genuinely collide with it, unlike the ordinary all-loop-scoped case above. Loop variables are therefore grouped per container and name: the first whose box actually claims a container-level name is the keeper and keeps its name; every other direct-child loop variable with that name in the same container is force-shadow-renamed. The claim test mirrors the emission exactly — the var escapes AND is not inherently heap-allocated (a pointer/slice/map/chan/interface/func var is already a reference and gets no box) AND the box actually hoists (per the split above). A group with no claiming var is untouched, so ordinary same-named sibling loops keep their Go names — a claiming sibling would otherwise emit a duplicate hoisted box in the same scope (CS0128), and a non-claiming sibling’s loop-scoped variable (or deferred in-body box) nests inside the block that owns the box name (CS0136). (The historical motivating cases — runtime typesEqual’s for i := 0 pair inside one switch case and runqputslow’s three for i := … loops — now box per-iteration inside their bodies and no longer claim container names at all; EscapedLoopVarSiblingIndex keeps guarding the sibling grouping and was re-baselined to the per-iteration shape.) A function-body-level keeper is additionally recorded as function-level (so non-loop uses elsewhere shadow-rename as before), but never masks a real function-level declaration — preserving the ForVarMasks… invariant above. A name group with no escaped variable is untouched (loop-scoped in C# too — no churn).

Escape analysis marks only the arg’s storage ROOT, not every identifier in a pointer argument. Passing an expression to a pointer parameter escapes the storage the pointer refers to — the peeled root of a literal &expr (through parens, field selectors, index expressions, and derefs), or the bare identifier itself. An identifier appearing merely in a subexpression of the argument contributes a value, not its own address: in xs[i].link(&xs[i+1]) or typesEqual(tin[i], vin[i], seen) the container (xs/tin’s elements) escapes but the index i does not. The old contains-anywhere check heap-boxed every such loop index — a spurious allocation on a hot path (Go keeps these in registers), gratuitous Ꮡi machinery in the emitted code, and the very duplicate-hoist collisions the grouping above then had to resolve (typesEqual’s pair now emits two plain for (nint i = 0; …) loops, no boxes, no renames). A direct &i anywhere — including nested inside a larger argument — is still caught independently by the address-of analysis. And a renamed variable used as an LHS index/map key is rewritten there too. An assignment a[i] = … / m[ns] = … / p.f[k] = … reassigns the root (a/m/p); the index/key expression is a separate value, so a shadow-renamed variable used there (a[iΔ1], m[nsΔ1]) must be rewritten by descending the target’s index/selector/deref chain and renaming each index. Missing this is a silent bug — the LHS key kept the enclosing variable’s name, so m[ns] = nsΔ1*100 wrote to the wrong key with no compile error — as well as a CS0136/CS0165 once the loop variable itself is renamed. (Both guarded by the EscapedLoopVarSiblingIndex behavioral test — the array case would not compile and the map case would silently return the wrong value without the pair, its boxedSiblings extension covers two genuinely-escaping siblings in one switch case (both take &i; first keeps the name, second renames), and its caseSiblings extension proves the index-only pair stays UNBOXED; cleared the 2 runqputslow CS0136, a CS0841, and the 2 typesEqual CS0128.) The target-chain descent also visits a method-call receiver in the chain — x.ptr().Value.next = … (runtime stackpoolalloc, where the loop x is renamed xΔ1 because a func-body x is declared after the loop). The x is buried inside the x.ptr() call, past the selector/index steps, so without visiting the call the use kept the raw x — read before its (later) declaration → CS0841, or a silent wrong bind. Visiting the whole call renames its receiver and argument identifiers (the call’s result is the navigated base, so the descent stops there). (Guarded by the ShadowedVarMethodCallLHS behavioral test — write-through through the method verified vs Go; cleared the stack.cs CS0841.) A TYPE ASSERTION in that chain is a navigation step too, and its absence stopped the descent dead. n.Values[0].(*ast.CompositeLit).Type.(*ast.ArrayType).Len = nilgo/typesgenerate_test.go, inside ast.Inspect(f, func(n ast.Node) bool { switch n := n.(type) { case *ast.ValueSpec: … } }), where the case variable shadows the literal’s n parameter and is renamed nΔ1. The assertion was in no arm of the descent switch, so everything below it went unvisited: the chain’s root ident kept its raw name and bound the enclosing ast.Node parameter, emitting (~n).Values[0]…~ applied to an interface, CS0023 — while the if condition one line above, an ordinary expression, renamed correctly. getIdentifier does not see through an assertion either, so unlike a paren-rooted target there is no reassignVar fallback to catch the root. The descent now visits the assertion’s operand subtree whole (the treatment the method-call arm already uses): it carries every ident that needs the rename — the root and any index below it — while the asserted type carries none. Where the raw name still type-checks this was a silent wrong bind rather than a compile error, which is why the guard compares values against Go rather than merely compiling. (Guarded by the AssignThroughTypeAssert behavioral test — the go/types witness, a renamed loop index below the assertion in the same chain, the compound-assign form, an assertion at the very root of the target in a default arm, and a chain-read control that already worked; the pre-fix converter regresses five emission sites, four of them CS0023.)

The reverse collision — a package method named like a built-in — needs the opposite treatment. In Go a method func (b *pageBits) clear() and the universe clear built-in coexist: the method is only ever reached as b.clear(), while a free clear(s) is always the built-in. But the method is emitted as a clear(this ref pageBits) extension on the package’s static class, and C# member lookup binds that same-class member for an unqualified free clear(s) call — shadowing the using-static go.builtin.clear and failing (CS1620/CS1503). So a built-in call whose name the package also declares as a method/function is emitted qualifiedbuiltin.clear(s) — which resolves to the golib built-in regardless of the same-class shadow; the method call stays b.clear(). (This also required golib to gain the Go 1.21 clear built-in itself, in slice/span/map forms — plus an IMap<TKey, TValue> overload for a named map type’s value: the generated wrapper implements IMap<K,V> and forwards to the shared underlying map, so clear(h) on an http.Header-style named map empties the caller’s storage, and a nil named map stays a no-op (net/http/httputil’s clear(h), CS0411 without it). Guarded by the ClearBuiltinShadow behavioral test — including a named-map value cleared through an alias and a nil named map; runtime hit the original shadowing on pageBits.clear/sweepClass.clear, ~11 errors.)

For a function-literal parameter that shadows an enclosing local, the rename must reach the parameter declaration itself, not just the body: run(func(n int){ … n … }) where an outer n is in scope emits run((nint nΔ1) => { … nΔ1 … }). The body’s uses already resolve to nΔ1; if the signature still declared the bare n (the raw name), the body’s nΔ1 would be undeclared (CS0103). The parameter name in the emitted lambda signature therefore comes from the same shadow-aware identifier mapping as the body (the raw name when nothing is shadowed, so plain function types and non-shadowing parameters are unchanged). (Guarded by the ClosureParamShadow behavioral test; the runtime hit this pervasively on mcall/systemstack(func(gp *g){…}) where the closure’s gp shadows an outer gp, ~40 CS0103.)

Conversely, a local that shadows a pointer parameter must not inherit the parameter’s special emission. A deref-aliased pointer parameter is ж<T> Ꮡp with ref var p = ref Ꮡp.Value, so passing it whole to a *T-expecting function emits its box Ꮡp. But a local t shadowing a t *T parameter (func mapKeyError2(t *_type, …){ … var t *_type; … }) is a plain pointer local — passing it should stay use(tΔ2), not use(ᏑtΔ2) (the spurious & references an undefined ᏑtΔ2 box → CS0103). The bug was that the “is this a parameter?” check matched by name, so the shadowing local was misclassified; it now verifies the resolved object is genuinely one of the function’s parameter objects, not just a name match. (Guarded by the ShadowedPointerParam behavioral test; runtime hit this on mapKeyError2/interhash’s inner var t *_type, ~11 CS0103.)

Type-vs-Method Name Collisions

Go keeps types and methods in separate namespaces, so a package may legally declare both a type foo and a method foo on some receiver. In C# both land in the same package class — the nested type and the [GoRecv] extension method — where a type and a method cannot share a name (CS0102). The converter resolves this by Δ-prefixing the type (Δfoo) while the method keeps its core-sanitized name (foo), so they no longer collide.

This needs an extra step when the colliding name is also a golib reserved word (slice, array, channel, map, …). Such a name is Δ-prefixed anyway — to avoid the golib runtime type (slice<T> etc.) — so the method too becomes Δslice, and the plain Δ no longer separates type from method. In that case the converter appends the type marker to the type only, giving it a name distinct from the method:

[GoType] partial struct Δslice {  }                          // Go `type slice struct{…}`
[GoRecv] internal static Δslice Δslice(this ref builder b, ) // Go `func (*builder) slice(…)`

Only the type side is renamed; the method (and every call site and go2cs-gen-generated pointer-receiver overload) stays Δslice. This is deliberate: the go2cs-gen generators compute method names independently, so renaming the method would desync them — renaming the type keeps the converter and generators in agreement (the generators read the type name from the emitted C# syntax/attributes). This mirrors the Go runtime’s type slice struct{…} (the GC slice header) versus func (*userArena) slice(…).

A struct field named like a colliding package-level identifier is not renamed: a field is struct-scoped (g.trace does not collide with a package type/method trace in C#), so the field declaration keeps its core-sanitized name (trace). The box-field accessor static the TypeGenerator emits for it is therefore g.Ꮡtrace (the -prefixed declared member name). The converter’s &g.field address form (Ꮡg.of(g.Ꮡtrace)) must use that declared field name — it derives the accessor member from getCoreSanitizedIdentifier plus the type-colliding rename, not from the general identifier path that applies the package-level collision Δ-rename. Using the latter would emit g.ᏑΔtrace, which has no matching generated static (CS0117). Reserved-word fields keep their Δ (the field really is declared Δarray for a field named array), so the accessor is +the declared name in every case. (Guarded by the CollisionFieldBoxAccessor behavioral test; runtime hit this on g/m/p’s trace/stack/p fields, ~20 CS0117.) The generated accessor’s accessibility matches the field’s (its exportedness), not the field type’s name — an exported field Fun [1]uintptr (C# array<nuint> Fun) yields a public ᏑFun, so another package’s other.of(ITab.ᏑFun) can reach it; deriving the scope from the type’s simple name (array → lowercase → internal) would make the cross-package accessor unreachable (CS0117 in runtime’s iface.go walking abi.ITab.Fun).

One case does rename the field: when its name equals its enclosing type’s name and that type is itself Δ-renamed for a type-vs-method collision. internal/trace’s type Label struct{ Label string } sits alongside func (e Event) Label() Label, so the type becomes ΔLabel; the field, whose name equals the type, is renamed to differ (CS0542 — a member cannot share its type’s name). The existing rename prefixed a single Δ, but that yields ΔLabelequal to the renamed type, so the collision persisted. typeCollidingFieldName now doubles the marker (ΔΔLabel) when the name is a package-level collision, exactly as it already did for the keyword-family case (a reserved-word type is Δ-renamed too). Deterministic from the name, so the field declaration, the keyed composite-literal key, and every access site all agree:

[GoType] partial struct ΔLabel {                 // Go `type Label struct{ Label string }`
    public @string ΔΔLabel;                      // field name == type name, doubled to differ
}
 new ΔLabel(ΔΔLabel: e.label, )                // composite key
 l.ΔΔLabel                                      // access

(Guarded by FieldNameTypeMethodCollision — a Label field in a Label struct with a colliding Label() method, read/written through a value, the method result, and a composite literal.)

The double must also apply across packages. typeCollidingFieldName keys the double on the current package’s nameCollisions map, which is populated only for the package being converted — so a cross-package access of such a field (internal/trace/testtrace reading a Label’s field) emitted the SINGLE-marker l.ΔLabel against the declaration’s double ΔΔLabel — CS1061. The access site now consults the FIELD’S OWN package: fieldTypeIsRenamed derives the enclosing named type from the selector and asks packageHasMethodNamed(type.pkg, type.name) (a cached per-package scan of every func/method name — a type-vs-method collision Δ-renames the type), threading the result through a new fieldTypeIsRenamed ident context so convIdent upgrades the single marker to the double for the foreign case (the in-package case already doubled via nameCollisions, and its result is left untouched). CNR byte-identical (the pattern is absent from the single-package corpus except the guard). This is the FIELD-access counterpart to the cross-package renamed type-reference substitution (getCSharpTypeName / getScopeCheckedTypeName, further below) — that one covers naming the renamed type, this covers accessing its field; internal/trace/testtrace needs both. (The type-reference half — trace.Time/Event/Stack in a func signature, or *time.Location as a box element — is also resolved: a fresh full reconvert renders traceꓸTime/ж<timeꓸLocation> correctly through the convertToCSFullTypeNamegetAliasedTypeName path described in Foreign renamed types reference the recorded imported-type alias below. It was mis-diagnosed as a still-open root off a stale overlay whose importedTypeAliases were not populated.) (Guarded by the CrossPkgUser/CrossPkgLib extension — CrossPkgLib.Marker, a Marker field in a Marker struct alongside a Sensor.Marker() method, its field read across the assembly boundary through an inferred-type value; vs Go.)

The same struct-scoped rule applies to a keyed composite-literal field name. Frame{funcInfo: f}, where the field funcInfo is named like a colliding package type/method (declared unrenamed as funcInfo), must emit the C# initializer key funcInfo: — the package-level Δ-rename that convExpr would apply yields ΔfuncInfo:, which is not a parameter name of the generated constructor (CS1739). convKeyValueExpr therefore emits a struct-field key whose name collides at package level via getCoreSanitizedIdentifier (the declared name), not the general identifier path. (Same CollisionFieldBoxAccessor test; runtime hit this on Frame{funcInfo: …} in symtab.)

The type half of the same accessor (receiver.of(Type.Ꮡfield)) needs care too. Go code routinely names a local after its own type — m := getg().m, where m is a *m — so taking the address of one of its fields (&m.park) emits m.of(m.Ꮡpark), in which the bare type reference m binds to the variable m (a ж<m>, which has no Ꮡpark) instead of the type (CS1061). Because a converted struct is nested in its package’s static class, the converter qualifies the type with that class — m.of(runtime_package.m.Ꮡpark) — which a same-named local cannot shadow. A bare m (binds the variable) and a go.m (the struct is not a direct member of the go namespace) both fail; the package-class qualifier is the correct form. This is applied only on a collision (the .of() receiver variable’s name equals the type’s simple name), so every other box accessor keeps its un-namespaced, Go-like form — no golden churn. (Guarded by the VarNamedAsType behavioral test; runtime hit this on m/Δp locals taking field addresses, ~9 CS1061.)

The same collision fires when the receiver is that variable’s lambda capture. Inside a closure the captured variable renames to its capture copy (mʗ1), so the receiver-equality check alone misses it — but the enclosing local m is still visible to the C# lambda, so the accessor’s bare owning-type reference binds to it all the same: runtime rwmutex.lockSlow’s systemstack(func() { …; notesleep(&m.park) }) emitted mʗ1.of(m.Ꮡpark) → CS1061. boxAccessorType therefore also qualifies when the receiver is the type name plus the capture marker (typeName + ʗ…), yielding mʗ1.of(runtime_package.m.Ꮡpark). (Guarded by a further extension to CollisionFieldBoxAccessorcapturedLocalNamedAfterType, a type-named local field-addressed inside a capturing closure, write-through verified vs Go; cleared runtime rwmutex’s 2 CS1061, 91 → 89.)

The type half also needs the type-vs-method collision rename (above). When the accessor’s owning type is itself a colliding name — type funcInfo versus a method func (f *Func) funcInfo(), so the type is declared ΔfuncInfo — taking the address of one of its fields must use the renamed type (Ꮡ(f).of(ΔfuncInfo.Ꮡnfuncdata)); a bare funcInfo.Ꮡnfuncdata binds to the package’s static funcInfo method group (CS0119). The boxAccessorType helper applies the Δ-rename to a bare same-package collision name before its receiver-shadow check (the renamed name no longer matches a raw-named local, so the two disambiguations compose). (Guarded by an extension to CollisionFieldBoxAccessor — a global whose type is the collision type; runtime hit this in symtab’s pcdatastart/funcdata.)

A collision-renamed owning type is qualified unconditionally, not just when it equals the .of() receiver — because a Go local named after its type is renamed to the same Δ-name, so such a local anywhere in the function shadows a bare Δp.Ꮡfield (C# locals are function-scoped). Runtime’s malloc persistentalloc1 does persistent = &mp.p.ptr().palloc and then declares a local p further down (renamed Δp); the accessor (~mp).p.ptr().of(Δp.Ꮡpalloc) bound its bare Δp to that later local — CS0841 (use-before-declaration), and CS1061 regardless (the local’s type has no Ꮡpalloc). The receiver ((~mp).p.ptr()) is not the colliding local, so the receiver-name check missed it. boxAccessorType now qualifies whenever the type name is Δ-prefixed (a type is never shadow-renamed — types are package-level — so a Δ-prefixed accessor type is always a collision rename), emitting (~mp).p.ptr().of(runtime_package.Δp.Ꮡpalloc). Qualifying is value-identical to the bare form when nothing shadows, so it is safe to apply to every collision-type accessor. (Guarded by a further extension to CollisionFieldBoxAccessorlocalShadowsCollisionType, a local named after the collision type declared after the accessor; cleared runtime malloc’s CS0841 plus two mheap Δp.Ꮡgcw CS1061 of the same shape, 148 → 145.)

The three receiver-shadow arms above (.of() receiver equals the type, its capture, its box) and the collision-rename arm are all special cases of one general rule: a box accessor’s bare owning-type spelling Type.Ꮡfield binds to any same-named variable that C# has in scope, and C# scopes a local to its whole enclosing block regardless of where it is declared or whether it participates in this accessor at all. So boxAccessorType also qualifies whenever a variable of the type’s name is declared anywhere in the current function — receiver, parameters, results, or a local at any nesting depth (func literals included), collected into funcScopeVarNames during variable analysis. The general case, unrelated to any type-vs-method collision, is vendored poly1305 under -tags purego: mac_noasm.go declares type mac struct{ macGeneric }, and func (h *MAC) Sum(b []byte) declares var mac [TagSize]byte, so reaching the promoted-embed method h.mac.Sum(&mac) spelled Ꮡ(h.mac).of(mac.ᏑmacGeneric) in which mac bound to the array<byte> local (CS1061 ×2). It errored precisely in Sum/Verify (which declare that local) and not in Write (which does not) — confirming the diagnosis. Qualifying is always value-correct: Go guarantees the reference is unambiguous, and inside a scope that shadows the type the type is simply unreachable by that bare name, so every bare occurrence the emitter produces is meant as the type. Emitted forms (from the LocalShadowsEmbedHopType guard):

(h.acc).of(acc.inner).Add(p);                                             // Write: no local named `acc` — stays bare
(h.acc).of(main_package.acc.inner).Store(acc);                           // Sum: `var acc [4]byte` shadows the type — qualified
(d.deep).of(main_package.deep.acc).of(main_package.acc.inner).Store(deep); // Verify: both hop types shadowed (nested-block locals)

This is a pre-existing shadow class the converter always had latent; adopting -tags purego for the standard-library conversion (see The standard-library conversion applies -tags purego below) is what first reached the poly1305 code that exercises it — unblocking poly1305 and its 17 dependents (chacha20poly1305crypto/tlsnet/http, net/rpc, net/smtp, expvar). (Guarded by LocalShadowsEmbedHopType, whose Write/Sum/Verify discriminate qualified-vs-bare by whether the method declares the shadowing local; CollisionFieldBoxAccessor’s boxRefCapturedValueNamedAfterType golden was re-baselined — it previously compiled Ꮡw.of(w.Ꮡpark) only because C#’s identical-simple-name rule happened to bind the type; it now qualifies uniformly like every other shadowed accessor, a strict improvement with identical runtime output.)

A related case is the box name of a shadow-renamed receiver/parameter. A deref-aliased pointer (a receiver or a *T parameter) is emitted as ref var <name> = ref Ꮡ<raw>.Value — the companion always keeps the raw Go name, even when the value alias is shadow-renamed for a collision (func (p *cpuProfile) add() where p collides with the type pref var Δp = ref Ꮡp.Value). When a pointer-receiver (capture-mode) method is then called on that receiver/parameter, the call routes through the box, and that box reference must use the raw name Ꮡp — the value alias Δp would yield ᏑΔp, which is not in scope (CS0103). The converter builds the box from the raw identifier name (not the shadow-renamed value form), but only when they differ — so non-renamed receivers are unaffected (no churn).

The same raw-box-name rule applies when such a shadow-renamed pointer is captured by a closure (where the value alias is referenced through its box, since the ref-local can’t be captured — see the box-ref section below). A value use inside the closure becomes Ꮡp.Value.n and a field-address use Ꮡp.of(T.Ꮡn) — both rooted at the raw box name Ꮡp, never the renamed ᏑΔp. The field-address form (&p.field) routes through the box-ref address path rather than the generic pointer-variable path: that generic path would prepend onto the closure’s box-deref read (Ꮡp.Value), yielding a double-boxed ᏑᏑp.Value (CS0103). Because the captured pointer’s box Ꮡp is the ж<T>, the field address is simply Ꮡp.of(T.Ꮡfield) — the same form as a captured value struct. (Guarded by the RenamedReceiverBox behavioral test, which exercises a shadow-renamed receiver calling a capture-mode method, plus a shadow-renamed pointer parameter both read through and field-addressed inside a closure; runtime hit this on p/Δp receivers calling methods like p.addExtra() and on closures capturing such pointers, ~12+ CS0103.)

A closure that captures an outer variable is emitted with a snapshot copy declared before the lambda — var sʗ1 = s; — and uses of the captured variable inside the lambda are rewritten to that capture name sʗ1. The capture-name mapping is keyed by name, which breaks on a self-shadowing initializer inside the closure: runtime mgcsweep’s systemstack(func() { s := spanOf(uintptr(unsafe.Pointer(s.largeType))); … }) declares an inner s whose initializer reads the outer captured s. Both the captured use (the RHS s.largeType) and the distinct inner binding were mapped to the same sʗ3, so the inner declaration emitted var sʗ3 = …(~sʗ3)… — its RHS binding to the not-yet-initialized inner variable (CS0841). The fix records the captured object alongside the name, and applies the capture name only when an ident resolves to that exact outer object; the inner binding falls through to its own (shadow-renamed) name. The emission is var sΔ1 = spanOf(…(~sʗ3)…) — the inner s shadow-renamed to sΔ1 (distinct from the capture sʗ3), its RHS correctly reading the captured sʗ3, and later uses of the inner s using sΔ1. Because the object check passes for every non-shadowing capture (the ident is the captured variable), it changes nothing outside this self-shadow case (zero golden churn). (Guarded by the ClosureSelfShadowCapture behavioral test — a captured pointer with an inner s := f(s) in a systemstack-shaped call-argument closure, output verified vs Go; cleared runtime mgcsweep’s CS0841.)

The same rule applies to an escaping local whose address is taken — var p _panic; … preprintpanics(&p) in runtime’s gopanic, where p collides with the type p. The heap allocation is ref var Δp = ref heap(new _panic(), out var Ꮡp), so the box is Ꮡp (raw) and &p must emit Ꮡp, not ᏑΔp. Crucially, the box-name rule is keyed to the rename kind, because the two kinds name their boxes differently: a type-collision rename prepends the marker (pΔp) but keeps the raw box (Ꮡp), whereas a nested-scope shadow rename appends the marker plus a counter (iiΔ1, iΔ2) and keeps the shadow box (ref var iΔ1 = ref heap<nint>(out var ᏑiΔ1), so &i correctly emits ᏑiΔ1). The converter therefore rewrites to the raw name only when the alias is exactly Δ+rawname (the collision form); a shadow-renamed or non-renamed var keeps its existing box name. (Guarded by the CollisionRenamedLocalBox behavioral test, with ForVariants/NestedVarShadow covering the shadow-rename form left unchanged.)

A nested closure must not clobber the enclosing closure’s capture state

The per-lambda conversion state — conversionInLambda (are we inside a closure body?) plus the capture-name maps (currentLambdaVars/currentLambdaVarObjs) — is what makes closure-body emission rewrite captured references to their box/copy forms: a captured local s reads as sʗ1, and the current method’s direct-ж receiver (func (s *Stmt) … emitted this ж<Stmt> Ꮡs, whose body alias ref var s = ref Ꮡs.Value is a ref-local that cannot be captured by a C# closure) reads through its box as Ꮡs.Value. That state was set on entering a closure but reset to false/nil on exit, not restored — so a closure that contains an inner closure had its state wiped the moment the inner one finished, and every reference in the outer closure body after the inner one fell back to the bare, un-rewritten name. For a receiver field-read that is a bare ref-local capture — database/sql (*Stmt).QueryContext’s s.db.retry(func(){ …; rows.releaseConn = func(err){…}; if s.cg != nil { … } }), where s.cg sits after the inner releaseConn closure — the emission was s.cg (CS8175, “cannot use ref local s inside an anonymous method/lambda”); the equivalent captured-local case silently split a variable between its bare form and its ʗ1 copy within one closure. The fix makes enterLambdaConversion/exitLambdaConversion a proper LIFO save/restore stack (conversionStack): entering pushes the current state and installs fresh state; exiting restores the enclosing closure’s state instead of resetting. A closure at top level still restores to false/empty (unchanged), so the change is inert except where a closure body continues after a nested closure — there the receiver box-read (Ꮡs.Value.cg, Ꮡs.Value.cg.txCtx()) and the captured-local copy name are now applied consistently across the whole body. (Guarded by the NestedLambdaReceiverField behavioral test — a direct-ж receiver method whose closure holds a nested closure followed by a non-call receiver field read, a field-method call, and another field read, all verified to render Ꮡs.Value.<field> and output-compared vs Go; cleared database/sql’s 2×CS8175 and re-baselined DeferValueFieldPtrReceiver whose defer-then-body sequence exercises the same restore.)

Test-variant name coherence: production names are pinned, test-side method declarators Δ-rename

The -tests pipeline re-analyzes the package over the whole variant universe (production files + _test.go files) but only emits the test files — the production .cs on disk were converted from the production-only universe and recompile into the test assembly as-is. Production symbol names are therefore immutable in a test-variant analysis: any collision a test file introduces must resolve by Δ-renaming the test-side declarator, never the production element. Two shapes (strings/sort blockers B2/B9), both resolved in performNameCollisionAnalysis:

The rename registry is object-keyed (testMethodRenames map[types.Object]bool) — the same-named production type/function keeps its plain emission at every other site — and session-scoped, initialized once per -tests conversion rather than per variant: both variants come from one go/packages load, so the external variant’s references to an internal-variant method (the export_test pattern) resolve by object identity to entries registered during the internal pass. The declaration renames in visitFuncDecl, and every reference follows through convIdent — a METHOD name through its isMethod arm (all selector emissions funnel there), and a package-level free function referenced as a call target or function value through the trailing identifier path, which the receiver/first-parameter case above made reachable (without it the declarator renamed while its call sites still emitted the bare name: CS0103). The go2cs-gen RecvGenerator reads the emitted name, so generated ж-receiver overloads follow automatically. Real emissions from the probes:

public static any ΔReplacer(this ж<Replacer> r) {  }   // strings export_test.go `func (r *Replacer) Replacer() any` — type stays bare
@string got = fmt.Sprintf("%T"u8, tc.r.ΔReplacer());     // EXTERNAL-variant call site (replace_test.cs) follows the internal rename

public static void ΔSort(this By by, slice<Planet> planets) {  } // sort example_keys_test.go `func (by By) Sort(planets []Planet)`
new By(mass).ΔSort(planets);                                       // test-method call sites follow
Sort(data);                                                        // dot-imported production call keeps its bare emission

Production conversions have no _test.go files in their universe, so the analysis is inert there (CNR byte-identical ×402). (Guarded by TestTestVariantPinsProductionTypeAgainstTestMethodCollision — declaration, internal call site, external call site, and the pinned type — and TestTestVariantRenamesTestMethodShadowingDotImportedFunction — rename + bare dot-imported call, with never-referenced and qualified-only same-named methods as discrimination controls.)

A NESTED package’s production GoImplement record anchors to the production metadata file

An EXTERNAL test variant’s collected GoImplement records are split across two anchor files, because the go2cs-gen ImplementGenerator hosts its output in the first class of the attribute-bearing file: a record whose generated adapter must be a member of the test package class goes to package_info_external_test.cs (whose first class is <pkg>_test_package), while a record that generates a partial/adapter on the production class stays with the production-anchored package_test_info.cs. isTestAnchoredImplementRecord decides which, by testing the implementer name against the production class’s qualifier.

The anchor is a file-level property — GetFirstClassName reads the first ClassDeclarationSyntax in the compilation unit — so two required anchors mean two files; this is a workaround for the generator’s positional contract, not an intrinsic need. The external unit is written only when the variant actually records test-anchored attributes, so utf8-class packages keep their single-file shape. It carries no [GoPackage] (the attribute-bearing partial stays in package_test_info.cs, CS0579) and no global using aliases (they must be declared once per compilation, CS1537).

The _test.cs suffix on package_info_external_test.cs is load-bearing. It is what excludes the file from the production project, via the shared csproj-template.xml <Compile Remove="*_test.cs;…"> glob and productionCSFiles. package_test_info.cs does not match that glob, which is why it needed its own explicit entry in the template — and why the external unit was originally named package_info_test.cs (2026-07-18) to ride the glob for free. That name was a near-anagram of package_test_info.cs, and the two sorted adjacent to package_info.cs in every converted package directory; it was renamed to package_info_external_test.cs on 2026-07-21 (“external test package” is Go’s own term for package <name>_test). Any future rename must keep the _test.cs suffix, or add an entry to the shared template — which re-emits, and so churns, every behavioral .csproj.

The live records qualify the implementer namespace-relative — WITHOUT the go. root: math/rand’s external rand_test casting *rand.Rand to io.Reader records ж<math.rand_package.Rand>. The test formerly compared only the BARE (rand_package.) and fully-rooted (go.math.rand_package.) forms, so a top-level package matched by accident — its relative qualifier is the bare form (sort_package.IntSlice) — while every nested package (math/*, text/*, net/*, encoding/*, container/*, hash/*, crypto/*) matched neither and mis-anchored to the test file. The generator then emitted a SHORT StructName (non-foreign structs are emitted unqualified) inside rand_test_package, producing ж<Rand> where no Rand is in scope — while the converter’s own cast site already assumed production anchoring (new rand.RandжReader(r) against using rand = go.math.rand_package;), so the pair failed as two CS0246s in the generated .g.cs. The relative qualifier is now recognized alongside the other two, matching the function’s stated contract.

package_info_external_test.cs and package_test_info.cs are MERGE-PRESERVING. After any change to this routing, DELETE both files and the package’s Generated/ directory before re-running the pipeline — otherwise a stale record persists in both anchors and masks the result.

Test suites REFERENCE the production project instead of recompiling it

The original -tests model — recompile the production .cs into the test assembly — duplicates the production types. That is harmless until another referenced assembly surfaces one of those types in its API: strings.ToLowerSpecial(unicode.SpecialCase, …) and hash.Hash : io.Writer name the type in the production assembly, while the test source binds a distinct recompiled copy. No compile-set adjustment repairs that identity split.

-tests therefore selects among three test-project models (selectTestProjectModel, recorded as testProjectModel in the manifest):

Both reference models bind the package under test as an ordinary imported package: exported aliases and implementation metadata load from the colocated package_info.cs, its types render package-qualified, and isSameAssemblyPkg is false. Their package_test_info.cs is a test-class-only metadata anchor; it never declares a local production partial.

The white-box extension has five coupled parts:

  1. The normal production scan already reads build-selected same-package _test.go files to stabilize alias-shadow spelling. That same cheap scan now reports whether an internal test file exists. Only then does the production .csproj emit <InternalsVisibleTo Include="$(AssemblyName).tests" />; packages with no internal tests remain byte-stable.
  2. Internal test files emit into <name>_internal_test_package, with using static <namespace>.<name>_package. Production declarations remain untouched. Test-host registrations retain the Go package name in the manifest but target this bridge class.
  3. go/packages loads production, internal and external test variants together. An external test reference is routed to the bridge only when its go/types.Object belongs to the production import path and its declaration position is in _test.go; production objects and same-spelled unrelated declarations keep their ordinary route. This is how io_test reaches ErrInvalidWrite from export_test.go without source rewriting or a generated alias contract.
  4. Test-contributed implementation adapters are owned by test metadata anchors — a MIXED suite has two. The generators host output in the FIRST class of the attribute-bearing file, and a mixed white-box assembly has two classes generated code must merge into, so the B4/B5 two-file split returns in mirror image: records whose generated partial must merge with a bridge-declared type (a BARE record name in the internal variant’s declared-name set — splitWhiteboxVariantRecords) anchor in package_info_internal_test.cs, whose first — and only — class is the bridge (also the bridge’s single static declaration and its [GoPackage] carrier); every other record — production-qualified, foreign, or external-declared — stays in package_test_info.cs under the external test class. Anchoring a bridge implementer in the external class would generate a phantom empty type there instead of merging with the real declaration. Deferred adapter markers are redirected only when the exact (struct, interface) pair appears in a test anchor (emittedAdapterPair); the anchored reference is composed <anchor>.<member> where the member comes from the record’s spelling (anchoredAdapterMemberNameadapterStructKey normalizes a qualified production struct to the generator’s foreign <pkg>_<Simple> form and leaves a variant-local name bare, exactly the generator’s local-vs-foreign naming split; composing from the cast site’s spelling instead emitted ParseErrorжerror where the generator wrote csv_ParseErrorжerror), and each pair remembers which anchor file recorded it. Imported production adapters keep pointing to their defining assembly. The generator also recognizes a collision-renamed embedded value property (ΔBuffer for embedded bytes.Buffer) as the same promotion hop, and scans the current compilation for friend-bridge box-receiver extensions by simple name when — and only when — the struct has no local declaration (the bridge spells its box parameter through the imported alias, this ж<Replacer>, and the metadata-only case is precisely the one whose discovery compilation is null).
  5. The metadata seed imports the production, bridge and external-test classes as needed, but its first and only declaration remains the selected test anchor. An internal-only suite’s bridge is both the test class and the bridge, so the seed imports it exactly once (a second, global import of the same class is CS8933). This keeps go2cs-gen’s positional anchor contract deterministic for mixed and internal-only suites. A MIXED suite’s package_info_internal_test.cs is therefore written UNCONDITIONALLY, records or not (2026-08-14). The file is not only a metadata anchor: it is the bridge class’s ONLY public static partial declaration. Every converted SOURCE file opens its package class bare — partial class registry_internal_test_package { — exactly as production and external-test sources do, with the modifier living in the metadata file; see package_info.cs’s TypeAccessibility section for the same division of labour applied to types. Writing the unit only when the variant contributed bridge-anchored GoImplement/GoImplicitConv records therefore left a record-less bridge with no static declaration anywhere, and an internal test file declaring a method on a production type — which converts to an EXTENSION method — is then CS1106. internal/syscall/windows/registry’s whole 6-verdict suite sat behind one such line, func (k Key) SetValue(name string, valtype uint32, data []byte) error in its export_test.go. Mixed suites that appear to escape it do so incidentally: sort, bytes and strings each happen to have a go2cs-gen RecvGenerator file that re-declares the class public static partial — a generator supplying a modifier the emitter owes. A record-less bridge writes an anchor whose sections are all empty, which is what the production and external-test seeds already do in the same situation. Measured: registry moves from build-blocked to 4 of 6 (residuals TestValues, a raw-address array reinterpret materializing a zero-length array<T>, and TestGetMUIStringValue); guarded by TestWhiteboxBridgeUnitIsWrittenWithoutBridgeRecords.
  6. The friend grant is inserted after template rendering, never as a template verb: a user-supplied -csproj template keeps its historical verb count and renders exactly as before (insertFriendAssemblyAccess, anchored on the first closing PropertyGroup). And the reference models’ anchored metadata writes treat the anchor class as the local type scope (metadataAnchorLocalTypes), while the recompile model’s anchored writes keep the historical production-local qualification — there the production class genuinely is local to the assembly.

A production ALIAS whose right-hand side is ANONYMOUS is carried across with its global using (2026-08-14). Go’s type CorpusEntry = struct{Parent string; Path string; Data []byte; …} (internal/fuzz’s fuzz.go) has no C# spelling of its own, so the production conversion LIFTS the anonymous struct to a real nested type and reaches it through a compilation-scoped alias — global using CorpusEntry = go.@internal.fuzz_package.CorpusEntryᴛ1; at the top of fuzz.cs. global using is scoped to ONE compilation, and a reference-model test project is a second one that does not recompile the production sources, so neither half crossed: nothing visited the declaration, nothing claimed the lift, and every test-side reference fell through to t.String() and emitted raw Go syntax into a C# file

internal Func<struct{Parent string; Path string; Data []byte; Values []any; Generation int; IsSeed bool}, error> fn;

— CS1031/CS1525/CS1003 cascades in minimize_test.cs and worker_test.cs, with all 52 of the package’s verdicts behind them. seedProductionAliasLifts now reads the production package’s own package_info.cs (which the test conversion already opens for its GoImplement pairs) and seeds both halves together: the alias into importedTypeAliases, so the test metadata file re-emits the global using, and the anonymous TYPE into productionAliasLiftedTypes, so every renderer spells CorpusEntry (liftedNameFor, consulted wherever liftedTypeMap was). Keying by go/types identity is exact here — production and test variants are type-checked in one go/packages load, so the alias’s right-hand side and every test-side reference are the same *types.Struct.

Narrow on both axes, deliberately. Only an anonymous right-hand side is seeded: a named RHS already renders through its own qualified name, and aliasing it would put avoidable global using names into a compilation where a test-local type could collide. And only an alias the production package_info.cs publishes is seeded, so a type is never rendered under a name the test compilation cannot resolve; an unexported alias to an anonymous struct publishes nothing and keeps the pre-existing route. Guarded by TestSeedProductionAliasLiftsCarriesLiftAndAliasTogether, which carries both negative controls.

internal/fuzz builds clean afterwards (0 errors, from four parse-error families) and then stops one layer further out, on an infrastructure root that is not this one and is worth recording precisely: its worker_test.go TestMain calls flag.Parse(), and the converted flag.CommandLine has never been told about the host’s own --json / --result / --junit / -timeout arguments, so the run dies with flag provided but not defined: -json before any test executes. In Go, testing.M registers those flags on flag.CommandLine before TestMain runs, which is what makes the same flag.Parse() legal there. This also corrects the board’s attribution of the identical symptom on go/internal/srcimporter (“the process the host launches is not the go2cs test host”): the process IS the host — what it lacks is the flag registration.

Fallback is based on mutation, not merely on a production-qualified record. Pointer/value adapters and the shared T → ж<T> boxing route are relocatable. A structural conversion involving a production type, or a numeric conversion whose two operands are both production types, would require an operator on a closed referenced type; recordsRequireProductionMutation returns errProductionAnchoredRecords, and the already-loaded variants are re-emitted once under recompile. The older black-box recordsRequireProductionAnchor gate remains conservative for the ordinary reference model.

The model is abstract, not an io patch. io is the first mixed-suite proof: its project now builds with one io_package.Writer identity, export_test.go lives in io_internal_test_package, external references bind the bridge by Go object identity, and test-owned adapters coexist with imported io adapters. Focused guards cover model selection, conditional internal-test detection, host targeting, mutation fallback, emitted-pair adapter ownership, alias-shadow stability, and collision-renamed embedded-value promotion. What the bridge owes the compiler once production is a REFERENCE. A 62-package regeneration sweep found five defects that share one cause: the bridge is the same GO package as production, so every same-package test in the converter reads a production declaration as LOCAL — while its C# now lives in a closed referenced assembly. Each was fixed at the layer that made the wrong assumption.

A sixth, in golib, was masked behind those compile failures: reflect reads a package class’s [GoPackage] stamp, not its NAME. GoReflect reconstructed a type’s Go package by trimming _package off the declaring class name — a heuristic the bridge breaks by design, since binary_internal_test_package hosts declarations Go-declared in package binary. Both readers (GoTypeName’s qualifier and PkgPath) now prefer the stamp, with the name-trim kept only for a hand-written class carrying none; the two agree for every ordinary converted package. encoding/binary catches it through Go’s own asserts twice: TestNoFixedSize compares the error text … not fixed-sized in type *binary.Person, and TestSizeAllocs NAMES its subtests from reflect.TypeOf(v), so a whole subtest set appeared under invented names with no Go counterpart.

Reference closure (the declaration-edge rule). The test project’s references are the direct-import set, plus the alias scan (B2c), plus — because binding any referenced type in C# requires the assemblies that type’s own declaration names — the declaration closure of that set (declarationClosureImports, both project models). Two declaration edges carry it: an interface’s base interfaces, and a struct’s field types. Interface bases: Go interfaces satisfy structurally and compose by embedding; C# interfaces are nominal, so the converter carries both shapes as C# inheritance at each interface declaration (getStructuralInterfaceBases): hash.Hash embeds io.Writer, io/fs’s fs.File lists Read/Close explicitly and does not embed io, and both emit a converted declaration that NAMES an io base. Such a base edge belongs to the declaring package’s import graph, so it appears in no test import and no alias using; DisableTransitiveProjectReferences (B2b) then hides the declaring assembly’s own io reference, and every site that names the interface fails CS0012:

Struct fields at a composite literal: the converter renders a Go composite literal as new T(Field: …) — a call to the fieldwise constructor go2cs-gen generates for a [GoType] struct, whose parameter list spells out every field’s type — so binding that call needs every field type’s assembly. testing/quick’s Config holds a Rand *rand.Rand, so image/draw’s quick.CheckEqual(orig, sqDiff, &quick.Config{MaxCountScale: 10}) fails CS0012: The type 'rand_package.Rand' is defined in an assembly that is not referenced at the new quick.Config(…) expression, with math/rand in no import list on either side. No interface closure can ever reach it — Rand is a struct, so the shape is invisible to a base-interface walk; it is the same missing-declaration-edge defect one type-kind over.

The alias scan cannot reach either class: the named type does bind by name — its own package is referenced — and what is missing is a package named inside that type’s own C# declaration. Three gates keep the closure minimal, because over-inclusion is its own defect — every extra reference is churn across the banked corpus plus a chance at a duplicate-type conflict:

A root’s own package is never an addition, and the external variant makes that load-bearing rather than theoretical: go/packages names it <pkg>_test, which resolves to no importable package at all, so a bytes_test struct literal whose field type is declared beside it fails the whole conversion with F14b’s loud resolve test project dependency "bytes_test": package bytes_test is not in std. Every root’s PkgPath therefore seeds the already-referenced set (its types compile into the test assembly, or bind through the production project reference the template already carries).

testing is skipped as a walk source (closureWalkable): it binds to the hand-owned core/testing shim per F15b, whose C# declarations are authored by hand and share only names with Go’s — Go’s testing.T embeds a common holding io.Writer, time.Time, sync.RWMutex and a dozen more, none of which the shim’s two-field T declares, so inferring C# edges from the Go declaration there is simply invalid. Every -tests compilation names testing.T, making this the widest over-inclusion the struct rule could possibly cause; nothing is lost, since the shim’s reference is fixed in the project template.

Each interface step runs the same types.Implements candidate match the converter uses at the declaration site (identical exported / non-alias / non-generic / method-set / strictly-fewer-methods gates) — deliberately taken before that function’s covered-by-embed skip and minimal-covering-set prune, so the result is a superset of the emitted base list and no emitted base’s assembly can be missing. Only the declaring package’s own imports are scanned, so a same-package base needs no separate visit (an interface implements its base’s bases too, so those candidates are found directly), and the output is a sorted set, so the map-ordered walk stays deterministic. It is a pure project-reference concern (the manifest dependency list stays import-derived), empty for a package whose named types carry no foreign declaration edge (unicode/utf8/path/cmp/itoa → unchanged), and {io} for io/fs, hash/maphash and crypto/hmac. Measured minimality: regenerating every banked package’s .tests.csproj and diffing is the instrument, and it is what rejected each looser rule in turn — an un-file-scoped seed drifted compress/gzip (context, crypto/tls, mime/multipart, net/http, net/url) and go/token (go/ast); a “named by value” struct edge drifted eleven more (encoding/binary, errors, hash/crc64, internal/fmtsort, math/rand, math/rand/v2, mime, os/signal, strconv, strings, testing/quick); an “any composite literal” edge — the empty form unscoped — still drifted three (encoding/binary, mime, testing/quick). Every one of those gates remains a zero-drift rule. The root-scoped empty-literal edge is measured the same way, and it is the one edge that is deliberately not zero: at the 63-package roster (2026-07-31), converting every package twice on one binary with that edge neutered and restored, it changes exactly one project — math/rand/v2, by exactly one line, <ProjectReference Include="$(go2csPath)core/internal/chacha8rand/internal.chacha8rand.csproj" />. That is the root set itself and nothing else: the three foreign-struct negatives (mime, testing/quick, encoding/binary) that rejected the unscoped form stay byte-identical, as does every other banked package. (Run that probe with the converter’s exit status checked — 63/63 conversions must succeed on both sides: a conversion that fails writes no csproj, so an ignored failure reads exactly like “no drift”, and that false-clean is how the <pkg>_test self-reference below first hid.) Guarded by the TestSelectTestProjectModel, TestRecordsRequireProductionAnchorGatesReferenceModel, TestWriteTestProjectReferenceModelBindsProductionProject, TestReferenceModelSeedAnchorsTestClassOnly, and TestDeclarationClosureImportsSurfacesForeignDeclarationEdges (foreign-package interface base, own-package structural base, transitive b → a → io, the fmt.State narrowing with its positive control, the empty-closure case, the *rand.Rand struct field at a composite literal with its by-value-only negative, a func-typed field’s signature, the one-level field boundary, the compile-excluded-file negative with its own positive control, the external-variant self-reference negative, and the testing-is-never-walked negative) converter unit tests; unicode (28 tests, incl. TestSpecialCaseNoMapping and the testing.Benchmark-driven TestCalibrate) is the first package it validated under the reference model, and crypto/hmac (172 tests) the first the generalized foreign-package rule unblocked. (io/fs compiles clean under this closure but does not yet validate: its TestCVE202230630 globs a 10 001-separator pattern whose faithful globWithLimit recursion overflows .NET’s fixed thread stack before Go’s depth > 10000 guard fires — a Go-growable-stack vs .NET-fixed-stack divergence in the test host, orthogonal to the reference closure.)

The third closure edge — a MEMBER ACCESS (2026-08-03, r38-gob). The two edges above are edges of a named TYPE’s declaration; the third is the edge of an access: resolving x.M requires BINDING x’s type, and when x is declared in another package that type is spelled nowhere in this compilation — not in an import, not in an alias using. unique is the witness: handle.go declares var cleanupMu sync.Mutex, the white-box suite calls cleanupMu.Lock(), and the test project referenced no synchandle_test.cs failed CS0012 … 'sync_package.Mutex' twice, no host linked, and the package’s suite had never been measured. Adding the production package’s whole import list instead is the looser rule the reference model exists to avoid (it would have added internal/stringslite and unsafe here, and far more elsewhere). The seed is every *ast.SelectorExpr’s BASE type, namedTypesIn-expanded, then reach()ed and enqueue()d like a field edge; a package-QUALIFIED selector (sync.Mutex, lib.F) is not this shape at all — its base is a PkgName, which has no type — and the import that spells it already carries the reference.

Measured minimality — two restrictions, each of which the roster REJECTED a looser form of. The probe is the one this section prescribes (regenerate every banked package’s .tests.csproj and diff, with the converter’s exit status checked on both sides: 73/73 conversions must succeed, since a failed conversion writes no csproj and reads exactly like “no drift”). (1) The receiver, not every named declaration. “The type of every var/const/func the compilation NAMES” is equally true of C#’s binding rules in the abstract, and drifts 23 of 73bufio into compress/bzip2 and image/gif, internal/abi + internal/reflectlite into errors, three references into hash/crc32 — all of which compile clean today with none of it. Naming a declaration does not force its signature to be materialized; accessing a member of it forces the receiver’s. (2) _test.go files only, scoped per FILE. Under the reference model the production sources are not in this compilation — they are in the referenced assembly, which carries its own references — and seeding from them too still drifts 13 (crc32’s castagnoliOnce.Do, math’s cpu.X86, …). Per-file rather than per-package because go/packages loads the INTERNAL test variant with the production files alongside its own, so a package-level gate lets every production receiver back in. With both restrictions the roster is zero-drift: unique’s single <ProjectReference Include="$(go2csPath)core/sync/sync.csproj" /> is the only line that changes across all 73. Under the recompile model the edge is a no-op by construction — that model adds production.Imports wholesale, so a production receiver can never be an addition. Guarded by TestDeclarationClosureImportsSurfacesMemberAccessEdges (the unique shape through real test variants, plus the named-but-not-accessed, production-source-only and package-qualified negatives). One more rule fell out of the family’s own testing-never-walked negative: testing must never be an ADDITION either, for the same reason it is never a walk SOURCE — its reference is fixed in the project template, which is why the caller strips "testing" from the import-derived set rather than passing it through as already-referenced, so reach() now honours closureWalkable too.

The fourth and fifth closure edges — a ZERO-VALUE DECLARATION and a RECORDED INTERFACE BASE (2026-08-07, r43f). log and go/scanner sat on the build-blocked list with one CS0012 family each, and each named a demand the first three edges structurally cannot see.

log — the zero-value declaration. TestNonNewLogger writes var l Logger. There is no composite literal anywhere in it, so the struct-field edge’s *ast.CompositeLit seed never fires — but Go’s zero value of a struct is not default(T) in the emission. The converter renders the declaration as a constructor call: ref var l = ref heap(new Logger(), out var Ꮡl) for the address-taken shape (escapeAnalysisOperations), new Logger() otherwise (visitValueSpec). C# overload resolution must materialize every accessible constructor’s signature before it can choose one, and under the white-box InternalsVisibleTo grant the package-under-test’s internal fieldwise overload is accessible — so log_test.cs failed CS0012 … 'atomic_package.Pointer<>' … assembly 'sync.atomic' at new Logger(), Logger’s three atomic.* fields being spelled in no import list on either side. This is the empty-literal edge’s exact demand by another route, so it feeds the same seed (constructedEmpty) under the same ROOT/accessibility gate, scoped to _test.go files for the member-access edge’s reason. math/rand/v2’s *p = ChaCha8{} and log’s var l Logger are now one rule.

go/scanner — the recorded interface base. A converted concrete type names no interface in its own emitted declaration — [GoType("[]ж<ΔError>")] partial struct ErrorList; names none at all. Its bases arrive as the VALUE-form [assembly: GoImplement<T, I>] records its package emits, which the go2cs-gen ImplementGenerator realizes as partial struct ErrorList : global::go.sort_package.Interface inside the declaring assembly. The metadata type therefore declares that base, and binding any member on it makes the compiler resolve the base list: list.Sort(), len(list), Ꮡlist.RemoveMultiples() and the generated ErrorListerror value adapter’s own m_value.Equals(…) all failed CS0012 … 'sort_package.Interface', thirteen times across the suite, with sort in no test import and no alias using. The edge hangs off the member-access seed the third edge already computes — it is what binding that receiver additionally costs — and interfaces are excluded from it because the base walk covers them already.

Measured minimality — and here the instrument rejected the rule C#’s binding rules appear to justify. “The interfaces the receiver’s type implements, taken from the declaring package’s imports” is the natural go/types statement of the edge, mirroring interfaceBaseCandidates one type-kind over. It drifts 16 of the 96 banked projects. The reason is that a GoImplement record exists only where the converter converted a cast, so Go satisfaction wildly over-approximates the emitted base list: os.File satisfies syscall.Conn and handed syscall to thirteen projects (compress/{flate,gzip,lzw,zlib}, image and four image codecs, io, math/rand/v2, regexp, strconv) — while os records File only against io/fs.File and io.Writer, and both POINTER-form, which generates an adapter class (FileжWriter) rather than a base on the type and so demands nothing of a member binding; bytes.Buffer satisfies most of io and handed io to sort and unicode/utf8 though bytes emits no records at all; internal/buildcfg’s Stringer handed it fmt from an equally empty set. All sixteen compile clean today with none of it. So the records are the gate and satisfaction merely supplies the candidate universe: packageImplementBases parses the package’s own freshly-written package_info.cs (value form only — parseExportedValueImplementLines already drops the pointer form), keys the interfaces by the implementing type, and the walk fires only where the receiver’s type and the candidate’s package both match a record. Keyed per type, because os’s one genuine syscall record is for rawConn, not File. With that gate the roster is zero-drift: converting all 96 banked packages on both binaries changes not one line of one .tests.csproj. The lookup is scoped to the package under test, whose package_info.cs this run has just written; a foreign type’s base list is its own package’s record set in its own package_info.cs, and no measured case has ever demanded one — widening is the same lookup pointed at that file.

go/types — the widening, measured. That last sentence held until go/types’ own suite, whose host build produced exactly one error: check_test.cs(78,66): CS0012 … 'sort_package.Interface', at len(list) in if list, _ := err.(scanner.ErrorList); len(list) > 0. The type is go/scanner’s ErrorList — the very type the same-package edge was built for, one package over. go/scanner is referenced (the suite imports it) so ErrorList binds; what does not resolve is the base list go/scanner’s own record set realizes on it, and sort sits in go/types’ PRODUCTION project references (the package imports it) and in no test import. So the records are now read per declaring package: a root type’s from the package_info.cs this run just wrote, a foreign type’s from that package’s, resolved through the same getImportPackageInfo route the <ImportedTypeAliases> block already reads a dependency’s metadata by (layout L3’s per-GOOS placement included) and memoized per import path. An unreadable or absent file yields no edge, exactly as it does for the package under test. The gate itself is untouched — same value-form records, same per-type key, same candidate match — which is why the widening does not reopen the 16-of-96 question: it points one lookup at a different file.

The other half: the member bindings that spell no selector. The same measurement moved the edge’s SEED as well. check_test.go never writes list.Sort() — it binds ErrorList only through len(list) and range list — so the member-access seed (x.M, the third edge’s) never saw it. Each of those lowers to a member on the value’s type all the same: golib’s generic len, the emitted enumeration, an indexer. The seed therefore also carries the types a compiled _test.go binds a member on through a builtin call, a range, or an index/slice (memberBound), and that set feeds the implemented-interface edge only — never reach/enqueue, because the value is spelled by a package the suite already imports. The boundary the member-access edge measured stays exactly where it was, and is pinned by the same negatives: a test that merely NAMES a value of such a type, or PASSES IT ALONG to a function with an exact parameter type (var r Rows; Order(r)), binds nothing on it and surfaces nothing. Seeding this edge from every named type instead would cross that line — the existing negative fails immediately — which is why the shape is the member binding rather than the mention. go/types’ reference set gains exactly one line, core/sort, and the host builds clean. (Guarded by TestDeclarationClosureImportsSurfacesForeignImplementedBases — the foreign positive plus four negatives (no resolver, empty record set, a record keyed to another type, and the name-and-pass boundary) and the range/index forms — and by TestForeignImplementBasesResolverReadsDeclaringPackageInfo, which pins the value-form-only parse, the memoization, and the silent-nothing on an absent file. Both fail against the pre-fix converter, and each half of the fix fails it independently.)

Guarded by the TestDeclarationClosureImportsSurfacesZeroValueVarDeclarations (the log shape through real test variants, plus the foreign-struct accessibility negative and the production-source scoping negative) and TestDeclarationClosureImportsSurfacesImplementedInterfaceBases (the scanner shape, plus the three negatives that pin the gate: no records ⇒ no edge even where types.Implements says yes, a record keyed to another type, and the receiver restriction) converter unit tests. go/scanner validates 11/11 on the fix. log builds and runs for the first time — seven of its nine test functions agree with go test — but does not bank: two roots stand behind the closure one, runtime.Caller’s unimplemented getcallersp stub (TestAll, the same row testing/slogtest carries) and TestDiscard’s exact allocation-count assert (the established alloc-profile class). ⚠ The Caller root closed on 2026-08-07 and log still does not bank — TestAll asserts the GO source file’s own extension and line numbers, which the converted program does not carry; see runtime.Caller works by severing the FUNNEL below.

An Example/Benchmark-ONLY test file is dropped from the compile set (Phase-4D file exclusion)

Example and Benchmark declarations are uniformly Phase-4D-deferreddiscoverTestDeclarations records them in the manifest with status unsupported (“… execution is deferred to Phase 4D”) and the differential oracle filters them from both sides (eligibleTerminalTestResults admits only included test-kind declarations). The option-a ruling (2026-07-24) extends that deferral from the declaration to the file: a _test.go file is dropped from the -tests conversion/compile set (selectCompileExcludedTestFiles) iff both

  1. every top-level declaration it contributes is a Phase-4D-deferred func Example*/func Benchmark* — imports do not count as declarations; any top-level var/const/type, or any other func (a Test/TestMain/Fuzz func, a method, or a mis-signatured Example/Benchmark), disqualifies the whole file (conservative by design; TestMain/Fuzz are deliberately out of scope). The classification is the exact isPhase4DExcludedTestFunc predicate discoverTestDeclarations uses (no receiver, no results, no type params, and either a zero-parameter Example* or a single-*testing.B-parameter Benchmark*), so a file qualifies only when it truly contributes nothing to the run registry; and
  2. no RETAINED test file references any object the file declares, resolved by go/types object identity across the loaded variant set (never filename or text) — a promotion fixpoint over both variants, so an Example a retained test takes by value ([]func(){ExampleWired}) keeps its file compiled, and a candidate promoted back to retained can in turn pull further candidates in.

The predicate is pure go/ast+go/types: go/token’s example_test.go declares only func Example_retrievePositionInfo() at top level (its type p = token.Pos / const bad / func ok live inside a raw-string literal fed to parser.ParseFile), so a text scan would wrongly disqualify it while the AST predicate correctly qualifies it. Excluding it is the demonstrated consumer: that external package token_test file, recompiled into go/token’s mixed whitebox+blackbox test assembly, names token.FileSet/ΔPos/Token — types the referenced go.parser/go.ast assemblies surface from the production go.token assembly while the recompile makes a second, local copy — CS0012. With the file excluded, go/token compiles.

Metadata representation — discovery stays intact; only emission + compile-membership are dropped. The excluded file’s Example/Benchmark declarations still appear in the manifest under their existing disclosed-unsupported status, and its source is listed in testSources with a distinct example-benchmark-only status. This is required for oracle exactness, not cosmetic: go test runs an Example that carries an // Output: comment (go/token’s does), so it appears in the raw go test -json stream, and the F6 census gate (manifestCensusGaps, computed over the unfiltered Go results) fails the comparison for any run declaration the manifest does not account for. Dropping the declaration would trip the census; keeping discovery — and dropping only the file’s .cs emission and its .tests.csproj compile item — keeps every already-filtered Example filtered and the differential oracle exact. The predicate lives in convertTestVariants (both project models honor it): discovery runs over every test file, emission over the non-excluded subset.

Blast radius (banked packages). Because the policy changes which files a banked package compiles, a GOROOT scan of every committed test suite (with go/token as the positive control) found 18 packages with a qualifying file — each re-validated with identical Test counts and rebanked to remove the excluded *_test.cs, its .tests.csproj compile item, the project references + using aliases the excluded file exclusively pulled in (verified by import analysis, e.g. math/cmplx’s fmt, encoding/hex’s os/io.fs/log), and — where the excluded external file was the sole contributor to the external anchor — the orphaned package_info_external_test.cs. One subtlety is guarded operationally: the metadata writer merges with the committed anchor, so a GoImplement/GoImplicitConv record contributed only by the excluded file (math/rand’s GoImplement<text.tabwriter.Writer, io.Writer>, from an Example that casts a *tabwriter.Writer) survives the merge as a stale record referencing a now-unreferenced assembly (CS0234); regenerating the anchor from a clean state (as a whole-corpus reconvert would) drops it. The 17 packages with no qualifying file stay byte-identical.

Guarded by the TestSelectCompileExcludedTestFilesDropsExampleAndBenchmarkOnly (positive: external Example-only + internal Benchmark-only), TestSelectCompileExcludedTestFilesKeepsExampleWithTopLevelVar (condition 1 negative), TestSelectCompileExcludedTestFilesKeepsReferencedExample (condition 2 fixpoint), and TestSelectCompileExcludedTestFilesKeepsTestMainAndFuzzOnly converter unit tests.

A package-qualifier using in a converted TEST SOURCE contributes a project reference

Test projects set DisableTransitiveProjectReferences=true, so an assembly the package reaches only transitively is invisible to the test compile (CS0234). aliasReferenceImports covers this by scanning using ALIASES for namespace tokens of packages in the transitive import closure and adding a direct project reference for each. It formerly scanned only the two metadata files, despite its contract covering a file-local package-qualifier using; it now scans the converted *_test.cs outputs as well.

The shape this misses otherwise has no textual import at all: math/rand’s default_test.go does not import os/exec, but testenv.Command(…) returns *exec.Cmd, so the emitted default_test.cs binds cmd.Value.Env and cmd.CombinedOutput() through the os/exec assembly while exec. never appears in any import list. Scanning the emitted source finds the qualifier using and emits $(go2csPath)core/os/exec/os.exec.csproj. The manifest’s dependency list stays import-derived — alias targets are purely a project-reference concern — and the scan is additive, so a package whose sources introduce no new qualifier is unchanged.

Shadowing the names go2cs itself spells (nil, golib names, emitter-spelled type names, C# keywords)

A census (2026-07-16) of every non-function predeclared Go identifier, the golib public top-level type surface, and the C# keyword list — checked against the three name-protection mechanisms (keywords @-escape, reserved Δ-rename, and the shadow analyses) with a minimal transpile-and-run repro per candidate — found five real gaps, each fixed and guarded by the ReservedNameShadows behavioral test:

Census rows verified fine with no action needed (each proven by a transpile-run-compare repro): locals named any predeclared identifier (nil, iota, error, any, comparable, rune, the numeric type names — Go-consistent shadowing carries over); user types named error or a predeclared type name in the common self-consistent cases; embedded predeclared fields (struct{ float64; rune; any; int; string } — the color-color form internal rune rune; compiles, with keyword-mapped embeds escaping only the field NAME: internal nint @int;); the golib Defer/Recover delegates (never spelled in emitted code — the defer machinery’s lambda parameters are inferred); and a local named heap alongside heap-boxing machinery (heap<nint>(out var Ꮡx) is a generic invocation, which a non-generic local simple name cannot shadow). Known residuals, documented rather than fixed (unreachable under default flags in any constructed repro, or pathological): a user TYPE named a numeric alias name (float64, int32, uintptr, complex64, …) in a package where an emission would be forced to spell that predeclared name through inference; and type string struct{} (the golib @string spelling is escape-identical to the keyword-escaped user name).

A parameter that shadows an imported package is renamed at its declaration too

A function parameter whose name equals an imported package the function references — crypto/rsa’s func emsaPSSEncode(…, hash hash.Hash), where hash shadows the hash package named in the signature type hash.Hash — is shadow-renamed by the variable analysis (hashhashΔ1) so it does not bind the using hash = hash_package; alias. Every usage already rendered the renamed name (convIdent reads v.varNames), but the parameter declaration was emitted from the raw param.Name(), so the signature kept hash.Hash hash while its uses were hashΔ1 — CS0103 at every use (40 sites in crypto/rsa, 27 in testing/quick’s rand). The declaration now resolves through the same v.varNames map, so it matches the usages:

func emsaPSSEncode(mHash []byte, emBits int, salt []byte, hash hash.Hash) {  hash.Size()  }
internal static () emsaPSSEncode(slice<byte> mHash, nint emBits, slice<byte> salt, hash.Hash hashΔ1) {  hashΔ1.Size()  }

A non-shadowed parameter maps to its own raw name (no churn). (Guarded by the PackageShadowParam behavioral test.)

A shadow-renamed pointer parameter completes the same rule on two more paths. A *T parameter is deref-aliased as ref var <value> = ref Ꮡ<raw>.Value, so its box companion Ꮡ<raw> always keeps the raw Go name even when the value alias is shadow-renamed — func decrypt(rand io.Reader, …) where rand shadows the math/rand-style alias becomes ref var randΔ1 = ref Ꮡrand.Value. (A) An address-of or by-pointer pass of that parameter must therefore use the raw box name Ꮡrand, not +value-alias ᏑrandΔ1 (which is not in scope, CS0103) — boxBaseName returns the raw name for a pointer parameter specifically (unlike an escaping shadow-renamed local, whose box is the shadow form ᏑiΔ1). (B) When a function has both a pointer parameter and a shadow-renamed value parameter, its signature is rebuilt through a separate updatedSignature path (not the generateParametersSignature path fixed above), which had kept emitting the value param’s raw name — so EncryptOAEP(hash.Hash hash, …) diverged from its hashΔ1 uses again. That path now resolves value-param names through v.varNames too, matching the primary fix. Together these cleared 50 errors (crypto/rsa 23 + testing/quick 27). (Guarded by the PackageShadowPointerParam behavioral test.)

A declaration shadowing a BUILT-IN makes the call an ordinary call

Go permits shadowing a universe built-in at any scope, after which a call through that name is an ordinary call to the declaration, not the built-in — math/big’s own tests declare make := func(z *Int) *Int { … } as a function-local and then call make(test.z). The converter’s built-in handling is keyed on the identifier’s name, so such a call was emitted with built-in semantics. Every built-in arm is now gated on the identifier actually resolving to the universe built-in (identIsUniverseBuiltin — go/types records a genuine built-in as a *types.Builtin object; anything else is a shadowing declaration), and a shadowed call falls through to the ordinary call path:

make := func(n int) int { return n * 2 }
fmt.Println(make(21))
var make = (nint n) => n * 2;
fmt.Println(make(21));            // was: fmt.Println(new nint()) — the argument dropped entirely

Seven built-ins had a name-keyed emission arm and so were affected: make (→ new nint()), new (→ @new<nint>() — both drop the argument, CS1503/CS1929), panic (→ the statement throw panic(x) in expression position, CS8115), print/println (a spurious variadic interface{} cast), and len/cap when the argument is a pointer-to-named-array (a spurious .Value deref from the auto-deref arm). close, min/max and recover already carried the *types.Builtin check; append’s arm self-bails on a non-slice argument; the remaining built-ins (copy, delete, clear, complex, real, imag) have no dedicated arm and already fell through. Two analysis paths shared the hole and were closed the same way: isTerminatingStmt treated a shadowed panic(…) as terminating (mis-deciding a switch case’s break), and the capture-mode scan treated a shadowed recover(…) as forcing the function’s defer frame.

Note this is the opposite direction from packageBuiltinShadows (see Type-vs-Method Name Collisions): there the call genuinely is the built-in and a same-named package method shadows the C# using static go.builtin, so the call is emitted qualified as builtin.<name>(…). Here the call is not a built-in at all. (Guarded by the BuiltinShadowLocal behavioral test.)

A local that shadows a PACKAGE name is not a package qualifier

Go lets a variable, parameter or receiver take the name of an imported package; from its declaration onward the identifier denotes the variable, and the package is simply unreachable in that scope. The standard library’s own test code does this freely — format_test.go has both func checkTime(time Time, …) and time := Unix(0, 1233810057012345600) inside TestFormat.

Every emission out of convSelectorExpr used to be passed through getAliasedTypeName, the QUALIFIED-NAME resolver. That function reads its argument as <package>.<member> and rewrites either half — a collision-renamed foreign member (time.Secondtime.ΔSecond), a Δ-shadowed import qualifier (color.RGBAΔcolor.RGBA), or a type alias (color.RGBAcolorꓸRGBA). Applied to a rendered expression, it fired on any base that merely shared a name with an imported package, so one function produced three different wrong answers depending only on which rewrite matched the member:

func checkTime(time Time, test *ParseTest, t *testing.T) {
    if time.Year() != 2010 {  }      // Year is not renamed
    if time.Month() != February {  } // Month is a renamed TYPE
    if time.Hour() != 21 {  }        // Hour is a renamed CONST
Δtime.Year()      // the import alias — the variable vanished
timeMonth()      // the type alias — a type used as a method
time.ΔHour()      // the const rename applied to the METHOD name

The resolver is now gated on the selector’s base actually denoting a package, asked through go/types (selectorBaseIsPackage, consulted by aliasResolvedSelector), so a shadowing binding is excluded by construction rather than by name. A non-package base could never resolve through the alias maps anyway — they are keyed <package>.<member> — so the gate states the property once instead of per emission site. This cleared 33 errors across five codes (CS7036, CS1061, CS1955, CS8130, CS1501) in time’s converted test suite alone. (Guarded by the PackageNameShadowing behavioral test, whose describe(time time.Time) calls all three member kinds on the shadowing parameter.)

A collision-renamed member keeps the file’s RENAMED qualifier

A package that collision-renames an exported const or var publishes it with the const: marker, which tells a consumer to keep the reference qualified through the package (time.ΔSecond) rather than alias it to a type. That arm carried the qualifier through verbatim — the raw Go package name — while the file’s actual using may be Δ-renamed because a same-named child namespace is visible (see importAliasOperations.go). Both halves have to move together:

import (
    "time"
    _ "time/tzdata"   // puts the `go.time` CHILD NAMESPACE in the assembly
)
 time.Nanosecond 
using Δtime = time_package;   // `time` alone would bind the go.time namespace

time.ΔNanosecond              // WRONG — CS0234, `go.time` has no ΔNanosecond
Δtime.ΔNanosecond             // emitted now

The qualifier is run through importQualifier when — and only when — it is a single segment, so an already _package- or global::-qualified spelling is untouched.

A DOT-imported collision-renamed member has no selector to carry the rename

import . "time" makes every exported member a bare identifier, which is what time’s external test files use. A foreign type still resolves correctly in that form, because foreignAliasedTypeName works from go/types rather than from the source spelling; a const or var had no equivalent, so Second, Minute, Hour, Nanosecond, UTC and Local — every one Δ-renamed in time because a Time method shares its name — emitted raw and bound nothing (CS0103 ×176 across five files). convIdent now resolves such a reference through the same recorded GoTypeAlias entries the qualified path uses (dotImportedRenamedMember), and emits the renamed member bare: a dot import renders as using static <pkg>_package, which exposes it under exactly that name. Only const:-marked entries are honored — a type entry resolves to a pkgꓸName global-using alias, which is the type layer’s business.

Multi-Result Values and Comma-Ok Forms

Many Go functions return either a single value or a “value, ok”/”value, error” tuple, where only the declared return arity selects the behavior. You cannot differentiate C# overloads by return type alone, so the runtime types expose a second overload distinguished by an extra discard argument. For map access, the “comma-ok” read routes through a two-value indexer using the discard sentinel :

var v1 = m["Answer"];            // single value: zero value if the key is absent
var (v2, ok) = m["Answer", ];   // comma-ok: (value, present?)

These two forms can behave differently — case in point, type assertions: the single-value form panics on failure, while the comma-ok form returns safely with a boolean success result. Type assertions convert similarly, through a generated _<T>() accessor:

var t = i._<MyType>();              // panics on failure
var (t, ok) = i._<MyType>();       // comma-ok, safe

The asserted type is a type position, so an assertion to a pointer type renders the pointer type ж<T>, not a value dereference: i.(*box)i._<ж<box>>(). (The starred-operand-is-a-type case previously emitted the type form only inside a (*T)(p) cast; a non-parenthesized *type fell through to the value-deref path and emitted box.Value — CS0426, since Value is not a member type of box.) (Guarded by the TypeAssert behavioral test’s *box assertion; runtime hit this in netpoll.go’s arg.(*pollDesc).)

An assertion to a NAMED interface must record the concrete implementation, even from a non-empty interface source. At run time _<T>() resolves a NAMED target interface only through a compile-time GoImplement adapter (golib TryTypeAssert’s structural duck-typing path exists only for anonymous interfaces — a named interface with no generated ᴛAs method is treated as a miss, not converted). So h.(encoding.BinaryMarshaler) — where h is a hash.Hash32 whose dynamic type is *digest — needs [assembly: GoImplement<digest, encoding.BinaryMarshaler>(Pointer = true)] or it panics at run time (interface conversion: … not encoding.BinaryMarshaler), after compiling cleanly. The converter recorded such implementations only for an empty-interface source; a non-empty interface source (the common hash.Hash32/sort.Interface/… case) recorded nothing, because getUnderlyingType on an interface-typed expression yields the interface, not the concrete dynamic type. convTypeAssertExpr now, for a non-empty interface source asserting to a named target interface, enumerates the package’s concrete types that implement both the source and target interfaces — exactly the dynamic types the assertion can succeed on — and records a GoImplement for each (probing the value then the pointer form, so a pointer-receiver MarshalBinary records against *digest). The both-interface filter keeps the set tight (adler32 → just digest); an anonymous target interface is excluded (it resolves through its ᴛAs method and needs no adapter, so recording one would be dead machinery). Known limitation: only the current package’s scope is scanned, so an external p_test assertion whose concrete type lives in the package under test, or a dynamic type imported from a third package, is not yet covered. This unblocked the hash/* marshal round-trips (TestGoldenMarshal). (Guarded by the InterfaceToInterfaceAssertion behavioral test — a Stringish-typed value asserted to a named Marshaler it implements, plus a comma-ok miss on a type that does not, output-compared vs go run; the pre-fix converter panics at the assertion.)

A named interface’s DECLARATION site also records its same-package structural implementers. The assertion-site recorder above fires only where a package itself performs the assertion, so it generates an adapter only when the ASSERTING package can NAME the concrete dynamic type. When it cannot, the code compiles yet the assertion misses at run time: math/bits asserts err.(runtime.Error) on runtime’s overflowError/divideError panic values, whose dynamic type is the unexported errorString. runtime never itself casts errorString to runtime.Error — its plain error casts recorded only errorString → error, never errorString → runtime.Error, which errorString satisfies STRUCTURALLY through its value-receiver RuntimeError() method — and math/bits cannot name the unexported errorString, so no site ever recorded the pair and err.(runtime.Error) returned ok = false, NRE-ing on e.Error(). visitInterfaceType now, at each non-lifted non-constraint named-interface DECLARATION, records a GoImplement for every SAME-PACKAGE concrete type whose method set structurally satisfies the interface (recordLocalConcreteImplementers), sharing the value-then-pointer recordIfImplements probe with the assertion-site recorder. The producer runs in the concrete’s HOME assembly — exactly where the go2cs-gen adapter must be generated — so the fix needs zero generator/golib change; errorString records against ΔError in runtime’s own package_info.cs and math/bits resolves through it. The scan is deliberately EAGER: same-package, non-interface, non-generic concrete types, structural (no cast required), with NO gate on the assertion actually occurring and NO gate on the concrete being exported (errorString is unexported yet escapes via the exported panic value). It COMPLEMENTS the assertion-site recorder (commit fcfe4a948) — which still covers cross-package concrete dynamic types the declaration-site scan cannot see (an external p_test assertion, or an imported dynamic type).

Measurement (an isolated A/B full-stdlib reconvert) put the blast radius at just 102 net-new GoImplement records, 7.1 % over a 1442-record base — no VOLUME gate was needed. Most of the gross additions are a CLEANUP, not new surface: a concrete→interface pair a downstream consumer used to record redundantly at its own cast site (go/parser held 49 *ast.Ident/… → ast.Expr/Stmt/Decl records) now lives once in the producing package (go/ast, +96), and consumers drop the duplicate through the existing importedValueImplements dedup — the record migrates to the assembly where the adapter belongs.

One narrow SHAPE gate WAS forced, by the build rather than by volume: recordIfImplements skips a pair whenever the go2cs-gen adapter could not FORWARD one of the interface’s methods (adapterCannotForward). The generator names a forwarding target in exactly one step — this.M() for a method whose receiver is the type itself, or this.Field.M() for one reachable through a SINGLE embedded field (its receiver is that field’s own type). Two shapes fall outside that and compile to an adapter referencing a non-existent member:

The gate lives in the shared helper so both recorders honour it, and it only ever SHRINKS the recorded set — it drops zero pre-existing records (the compiling corpus is green without any of these shapes), so the corpus stays a strict subset of what already compiled: no new CS1929 or CS1503. This unblocked math/bitsTestDiv*Panic{Overflow,Zero} assertions (5 of 6 validate end-to-end through the reconstructed adapter; the 6th, TestDiv32PanicZero, is gated by an unrelated golib gap — a hardware DivideByZeroException is not re-presented as a Go runtime.Error, and Div32 alone relies on the implicit division panic rather than an explicit panic(divideError)). (Guarded by the OptionalInterfaceStructuralAssertion behavioral test — a widget that structurally implements a narrower Tagger interface, never cast to it, held as any through a []any and asserted to Tagger; the any-typed operand makes the assertion-site recorder record nothing, so the declaration-site producer is the SOLE source of the widget → Tagger adapter, output-compared vs go run.)

A second gate is a NAME collision, resolved at write time. The adapterCannotForward shape gate was measured against a corpus build that reported only the long-standing reflect/value.cs .Clone blocker — but MSBuild SKIPS a failed project’s dependents, and flag and compress/zlib both reach reflect through fmt, so neither was ever built. With .Clone fixed, compress/zlib surfaced CS0102 + CS0111 ×5: go2cs-gen composes an adapter class name from the LAST DOT SEGMENT of each side (the same naming adapterTypeRef/valueAdapterTypeRef emit at cast sites), so zlib’s OWN Resetter and the compress/flate Resetter that its *reader also implements both compose readerжResetter in zlib_package — two .g.cs files declaring one class. The FORM is part of the name (the ж pointer prefix vs the value infix), so only same-form pairs collide.

The loser must be chosen by ORIGIN, not by record order: writePackageInfoFile now tracks, per recorded pair, whether EVERY producer was the declaration-site structural recorder (structuralOnlyImplementations; a DEMANDED record from any emitted cast/assertion site wins permanently), and its collision prune drops a structural-only pair whose adapter name a demanded pair already owns. That is the safe direction: no emitted C# names a structural-only pair’s adapter — it exists solely so a run-time assertion can resolve — whereas dropping a DEMANDED pair strands a real cast site on a class the generator never emits (CS0246). Two colliding structural-only pairs are broken by keeping the lexicographically first, so the outcome is deterministic regardless of map iteration order. A pair the ALIAS dedup will skip (the qualified duplicate of a record already carried under a package type alias) is excluded from ownership — CrossPkgUser’s type Tagged = CrossPkgLib.Labeled records badge under both names, and the qualified one would otherwise evict a local Labeled pair while itself emitting badgeᴠTagged. Corpus-wide the prune removes exactly one record. (Guarded by CrossPkgUser: *dial satisfies both the local Labeled and the foreign CrossPkgLib.Labeled and is cast to the foreign one, so the two pairs compose one dialжLabeled; without the prune the build fails CS0102: 'main_package' already contains a definition for 'dialжLabeled'.)

RETIRED (2026-07-25) — the two recorders above, plus the test-package scan, are GONE; a named-interface assert now resolves at RUN TIME. Everything from “An assertion to a NAMED interface must record…” through the ORIGIN paragraph is HISTORY, kept because it explains records that still exist. Its founding premise — that TryTypeAssert can only resolve a named target through a compile-time GoImplement adapter — stopped being true when the tiered interface shells landed (see Every eligible interface carries runtime duck-typing shells): golib’s AdapterBinder now constructs a wrapper for a structurally-matching dynamic value from the interface’s OWN assembly, so the three STRUCTURAL producers — recordAssertConcreteImplementers (assertion site), recordLocalConcreteImplementers (declaration site) and recordTestPackageImplementers (-tests type-side scan) — were deleted along with recordIfImplements, adapterCannotForward and the structuralOnlyImplementations collision prune they existed to arbitrate (~495 lines). They were never sound, only useful: each GUESSED an assertion’s dynamic type by enumerating types it could name, and was blind by construction to a dynamic type in a later-converted assembly (io/fs recorded subFS, never os.dirFS). What REMAINS is every DEMANDED record — an explicit conversion, a var _ I = T{} witness, a resolved-concrete empty-source assert — plus the Promoted and ConstraintProxy records; ImplementGenerator is untouched, so a declared conversion still gets its nominal adapter, still the ~1.1 ns fast path. The corpus effect, measured on a seeded 305-package reconvert: 1535 → 1324 records, −335 / +124 across 53 packages. Of the 335 dropped, 73 RELOCATE — the pair moves from the provider package to each consumer that demands it, so the emitted reference changes shape (io.SectionReaderжReaderio_SectionReaderжReader, and (Scored)(Verdict)4new CrossPkgLib_VerdictᴠScored(…)) — and 262 are simply gone; a scan of all 1919 corpus .cs finds 261 of those 262 named by no emitted C# whatsoever, which is the direct measurement of how speculative they were. Of the 124 added, 117 are the consumers’ half of those relocations and 7 are base-interface records the interface-inheritance prune had been suppressing under a now-deleted derived-interface record (flag.textValue → Value, net.UnknownNetworkError → error, runtime.errorString → error, …). (Guarded by five EXISTING behavioral tests that now carry ZERO nominal records for the pairs they assert — DerivedInterfaceStructuralProbe, OptionalInterfaceStructuralAssertion, InterfaceToInterfaceAssertion, AnonIfaceThroughPointerAdapter, IfaceToIfaceNarrow — so their output comparison IS the shell-resolution proof; and operationally by the banked suites, testing/quick 8/8 being the direct one, since its myStruct → Generator record was the test-package scan’s sole consumer.)

A local named-FUNC value record is exempt from the interface-inheritance prune. Independently, flag failed CS0246 on boolFuncValueᴠValue — a PRE-EXISTING defect the same masking hid, reproducible with the structural recorder reverted. A C# delegate cannot be a partial struct, so a GoImplement pair whose concrete is a named func type generates a per-interface ADAPTER CLASS rather than an entry folded into the type’s own base list. flag’s boolFuncValue is recorded against both boolFlag and Value, and boolFlag EMBEDS Value, so the subsumption prune dropped the Value pair as “covered by inheritance” — true for a partial struct, false for an adapter class, which is per-exact-interface. The ж-pointer form and the foreign-value form were already exempt (adapterClassImplementations); the local named-func value form now registers there too, so flag.cs’s new boolFuncValueᴠValue(…) keeps its record. This is the same reasoning that exempts new net_ConnᴠWriter(…), applied to the one adapter-class shape the list had missed.

The run-time structural match for an ANONYMOUS interface is SIGNATURE-aware, not name-only. (Ladder detail SUPERSEDED — ᴛAs was retired 2026-07-25 in favour of the duck-typing shells, and the two memoized tiers were folded into one per-interface itab cache on 2026-07-26; both are described under Every eligible interface carries runtime duck-typing shells. The signature-aware matching rule below is unchanged and current — only the tier it gates has moved.) An assertion to an anonymous (dynamically declared) interface — x.(interface{ Unwrap() []error }) — does not resolve through a compile-time GoImplement adapter; it resolves at run time in golib, where builtin.TryTypeAssert gates on Cache<TInterface>.Implements before invoking the generated ᴛAs conversion. That gate used to compare only method names, so two anonymous interfaces that share a name but differ in signature — errors.Is’s emitted is_typeᴛ1 { error Unwrap(); } and is_typeᴛ2 { slice<error> Unwrap(); } — were CONFLATED: a value whose Unwrap returns []error matched both, and constructing the wrong adapter (error Unwrap() bound to a []error-returning method) threw NotImplementedException from the adapter’s static constructor. The check now matches each interface method by NAME and SIGNATURE (parameter and return types), scoped to the value’s actual Go method set: TypeExtensions.StructurallyImplements resolves the value’s receiver element (a pointer box ж<X> exposes X’s value- and pointer-receiver methods; a plain value X exposes only its value-receiver methods) and requires, for every interface method, an extension method with the same name whose signature matches (the candidate’s first parameter is the receiver, which the interface method lacks). This also fixes a second defect on the SAME path: GetExtensionMethodNames collapses a closed ж<X> to the open ж<> generic definition (correct for MinBy-precedence single dispatch, wrong for a name-set membership test), so the old check admitted the pointer-receiver methods of every type — e.g. ж<errorString>, whose errorString has no Unwrap, matched is_typeᴛ1 because fmt’s *wrapError.Unwrap was in the collapsed set. Open-generic receiver methods (methods on a Go generic type, whose signature carries type parameters that cannot be compared against a concrete interface signature) keep the prior name-only match. On the same fix, error._<T>() (the single-value assertion err.(T) on an error) now routes an INTERFACE target T — including a dyn anonymous interface — through this general machinery instead of casting the carrier to error<T> (which threw InvalidCastException when the dynamic value was a generated pointer/interface adapter rather than an error<T>), first unwrapping a pointer-sourced IжAdapter to its receiver box so the structural probe sees the dynamic *T. This unblocked errors.Join/TestJoin end-to-end and made errors.Is route wrapped/multi errors to the correct Unwrap arm. (Guarded by the AnonInterfaceSignatureAssert behavioral test — a value whose Unwrap returns []error and one whose Unwrap returns error, each asserted against BOTH interface{ Unwrap() error } and interface{ Unwrap() []error }; the correct shape matches and dispatches, the wrong one misses. Output-compared vs go run; the pre-fix golib crashes the C# process at the conflated assertion.)

An interface that EMBEDS another interface must collect only the base’s INSTANCE, ORDINARY members — reflection’s default BindingFlags leak the base’s STATICS. Go’s interface{ error; Temporary() bool } (net.Error’s shape) converts to a [GoType("dyn")] partial interface classify_type : error, so both halves of the duck-typing machinery have to walk the C# base interface to see Error(). Both walks collected too much. golib’s TypeExtensions.GetInterfaceMethods called baseInterface.GetMethods() with default flags (Public | Instance | Static) where the direct-member call above it correctly passes BindingFlags.Public | BindingFlags.Instance — and golib’s hand-written core interfaces expose static duck-typing conversion helpers (error.As<T>, fmt.Stringer.As<T>), while TypeGenerator stamps ᴛAs onto every dyn interface. So StructurallyImplements demanded a static As from the dynamic value’s Go method set, which no Go type can ever satisfy: every embedding interface’s structural probe answered FALSE, and an assert against a value that plainly has both methods returned a MISS. Symmetrically, go2cs-gen’s InterfaceDeclarationSyntaxExtensions.GetInterfaceMethods walked AllInterfaces members with no IsStatic / MethodKind filter, so the Δ wrapper FORWARDED those statics — the two surviving As overloads emitted duplicate AsByPtr/AsByVal delegate types and s_AsByPtr/s_AsByVal fields (CS0102 ×6, so the project did not even compile). Both walks now filter to instance, ordinary members; property/event accessors and constructors are likewise never Go interface methods. Note the base walk must stay TRANSITIVE — .NET’s Type.GetInterfaces() already flattens an inheritance chain, so a 3-deep interface{ named; Depth() int }named : stringish collects all three levels. (Guarded by the DerivedInterfaceStructuralProbe behavioral test: an anonymous interface embedding error asserted against a *tempErr that has Error+Temporary and a *plainErr that has only Error, plus a 3-deep embedding chain with a deepest-level negative control, output-compared vs go run. Pre-fix the project fails to compile; with only the gen half fixed it compiles and prints not temporary where Go prints temporary=true msg=boom.)

A typed-error assert (err.(*T) / err.(T)) resolves through the SAME machinery — the error<T> carrier is only a special case. errorExtensions._<T>(this error) is the overload C# picks whenever the assert operand is statically an error (builtin._<T>(this object) is less specific), and its body used to be a direct cast, ((error<T>)target).Target. That only ever matched golib’s own reflective carrier — the object error.As builds — which converted code never constructs: an error produced from a Go pointer is a generated IжAdapter (new fs.PathErrorжerror(Ꮡ(new PathError{…}))), and one produced from a value is the implementing struct itself. So every single-value typed-error assert against real converted code threw InvalidCastException (“PathErrorжerror to error<ж<fs.PathError>>”) — and because that is a raw CLR fault rather than a PanicException, Go’s recover could not even see it. os’s dirFS.Open path-fixup (err.(*PathError).Path = name) died on it, taking io/fs’s TestGlob, TestReadDirPath and TestReadFilePath down as infrastructure errors. The comma-ok form was never affected: err._<T>(ᐧ) has no errorExtensions overload and already bound to builtin’s adapter-aware TryTypeAssert. _<T> now unwraps the error<T> carrier when the dynamic value actually IS one, and otherwise defers to ((object)target)._<T>() — the one type-assertion machinery — so a statically-error operand and an any-typed one can never disagree about the same assert. Commit cb0f58078 (above) closed the INTERFACE half of this defect; this closes the CONCRETE-type half, and the two runtime-Type overloads (err._(type), err._(type, out result)) were routed through builtin.TryTypeAssert(object, Type, out object) for the same reason, replacing a reflection lookup of error<>’s explicit conversion operator that had the identical carrier-only blind spot. (Guarded by the TypedErrorAssertThroughAdapter behavioral test — pointer- and value-sourced errors asserted back to their concrete types through an error boundary, a write through the asserted *T observed on the interface value, comma-ok misses in both directions, and a failed single-value assert proven to be a recoverable panic; output-compared vs go run, and the pre-fix golib crashes the C# process with exit code 2.)

Constructing the run-time duck-typing wrapper is FAIL-SOFT — a structural false positive must MISS, not crash. golib’s structural probe is deliberately imprecise in one place: for an open-generic receiver method it matches by NAME ONLY, because the candidate’s signature carries the receiver’s type parameters and cannot be compared against the interface’s concrete signature (see StructurallyImplements’ remarks). So a Go generic’s Get() T matches interface{ Get() string } for every instantiation — box[int] included, where Go plainly says no. The probe therefore said yes, builtin.TryTypeAssert closed the generated ᴛAs over box<int> and invoked it, the wrapper’s static initializer found no bindable extension overload and threw NotImplementedException, and reflection re-wrapped that as TargetInvocationException — which escaped the assertion as a process crash (Exception has been thrown by the target of an invocation., exit 2) where go run prints a clean comma-ok miss. A MISS is normal control flow at every emitted assertion and type-switch site (the method’s own remarks say so), so wrapper construction now runs through one fail-soft helper (TryConstructInterfaceWrapper) covering both close-over paths — by-value and by-pointer over a receiver box — and any construction failure answers ok=false: a MakeGenericMethod constraint violation, an already-faulted type initializer, a conversion whose result is not the asserted interface (the hard (T) cast became an is T pattern), or missing dynamic-code support. This is the same discipline golib’s CreateInterfaceHandler already applies. It narrows nothing: a genuine duck-typed assertion still resolves and dispatches. (Guarded by the StructuralAssertFailSoftMiss behavioral test — a real match then a false-positive probe, on each close-over path; pre-fix the C# process dies on the second line.) Superseded 2026-07-25 — the ᴛAs route this helper closed, TryConstructInterfaceWrapper itself, and CreateInterfaceHandler were all retired when anonymous interfaces moved onto the runtime duck-typing shells (see Every eligible interface carries runtime duck-typing shells…). The fail-soft rule is unchanged and now lives in one place, AdapterBinder.TryCreate; the guard still holds.

CLOSED 2026-07-25 (kept as the measured statement of the problem) — a NAMED interface had no run-time wrapper, so a cross-package structural satisfaction missed. Every named interface now carries runtime duck-typing shells (see Every eligible interface carries runtime duck-typing shells… below), io/fs validates 18/18, and the ᴛAs machinery described here no longer exists — anonymous interfaces use the same shells. The two recorders above (assertion-site and declaration-site) can only see the converting package’s scope, so an assert whose dynamic type comes from a package converted later — or from one the asserting package does not import at all — records nothing, and the run-time fallback cannot cover for it: builtin.TryTypeAssert’s structural path needs the generated ᴛAs/Δ<Iface><T> duck-typing wrapper, which TypeGenerator emits only for [GoType("dyn")] (anonymous) interfaces — 33 of them corpus-wide, against 267 named ones. Measured ground truth (io/fs, 2026-07-24): os.dirFS structurally satisfies fs.ReadDirFS/fs.StatFS and ж<os.File> satisfies fs.ReadDirFile, and golib’s structural PROBE agrees — builtin.Implements<fs.ReadDirFS>(dirFS) returns true for all three — yet each assert returns a MISS, because GetInterfaceConversionMethod finds no ᴛAs on the named target and bails. io/fs’s ReadDir therefore falls back to fsys.Open + file.(ReadDirFile), misses again, and returns readdir .: not implemented with zero entries; fs.Glob swallows that error (TestGlob sees an empty match set) and fs.WalkDir hands it to the callback (TestIssue51617 sees only .). Nominal recording can never be complete here — an interface’s dynamic type may live in any assembly, including one built after the asserting package — so the durable fix is to give NAMED interfaces the same run-time duck-typing wrapper dyn ones already get, with method collection made TRANSITIVE (a named interface inherits members through C# interface inheritance: ReadDirFS : FS needs Open as well as ReadDir). That is a corpus-wide go2cs-gen change and is not yet done; io/fs validates 16/18 with these two tests open.

The types that support these tuple-returns are defined in the golib library; ordinary user-code tuple returns convert as normal C# tuples without special handling.

A package-level var a, b = f() reads ValueTuple components. C# static field initializers cannot deconstruct a tuple, so the per-name field emission assigned the WHOLE result tuple to the first field (CS0029 — edwards25519’s var identity, _ = new(Point).SetBytes(…)). With exactly one non-blank name the component read is appended to the inline call (internal static ж<Point> identity = …SetBytes(…).Item1; — blank names keep their uninitialized _ᴛNʗ fields, and the call still runs once). With two or more non-blank names the call is evaluated ONCE into a hidden tuple field and each name reads its component (internal static (nint, @string) tupleᴛ1ʗ = pair(); internal static nint n = tupleᴛ1ʗ.Item1; — C# static initializers run in textual order, so the reads follow the temp). Gated to package scope, no explicit type, one call initializer typed as a tuple; in-function var x, y = f() keeps the existing path. (Guarded by the GlobalTupleVarDecl behavioral test — both shapes plus a call-count probe proving single evaluation, output-compared vs Go.)

Every trailing argument of a variadic pointer parameter gets the box treatment. The per-parameter argument loop visits declared parameters only, so checkInitialized(p, q) binding two deref-aliased pointer parameters to ...*Point boxed only the first (checkInitialized(Ꮡp, q) — CS1503). The pointer-argument box treatment now fans out from the variadic parameter’s index to every trailing argument, mirroring the type-parameter @string fan-out; the spread form (f(s…)) is excluded as before, and non-variadic calls are byte-identical. (Guarded by the VariadicPointerParam extension pairTotal — three deref-aliased pointer params forwarded to the variadic, value vs Go.)

A call-result delegate of a NAMED func type must resolve its signature through Underlying(). All the per-argument treatments above (pointer boxing, interface conversion, u8 suppression) are driven by getFunctionSignature, which for a callee that is itself a call — valueEncoder(v)(e, v, opts), encoding/json — read info.TypeOf(fun).(*types.Signature). When the inner function returns a named methodless func type (valueEncoder returns encoderFunc), info.TypeOf is a *types.Named, so that assertion failed and the signature came back nil — the per-argument loop never ran, and the pointer receiver e (a deref’d ref var e = ref Ꮡe.Value) passed its value alias where the ж<encodeState> slot wanted the box Ꮡe (CS1503). The *ast.CallExpr arm now asserts on Underlying(), looking through the named func type to its signature (a no-op when the result is already an unnamed signature). Byte-identical across the behavioral corpus and across an A/B of encoding/json+gob+text/template+net/http+reflect — a single line moves (json’s valueEncoder(v)(e,…)(Ꮡe,…)). (Guarded by the NamedFuncResultPointerArg behavioral test — adder() returning a named addFunc called immediately with a *State receiver that must box, mutation through the box observed vs Go.)

A variadic closure rebinds its params array to a slice at the top of its body. A variadic parameter a ...T arrives in C# as a params ꓸꓸꓸT array named <name>ʗp (a distinct name, so it doesn’t collide with the slice the body expects); the body then references the bare <name> as a slice<T>. A top-level function emits a var <name> = <name>ʗp.slice(); prologue as its first block statement, but a function literal emitted no such prologue, so any closure that referenced its variadic parameter used an undefined bare name (CS0103 — internal/dag’s errorf := func(format string, a ...any) { … fmt.Sprintf(format, a...) } spread a… against a name that was never declared). A function literal now emits the same rebinding prologue. Because the prologue is prepended before the single-return→expression-body collapse, a variadic closure whose body is a lone return f(a...) keeps its block form (the rebound name is a statement-scoped local) rather than collapsing to an undefined expression. An IIFE literal is excluded — it emits parameter names only (the raw a, with the delegate cast supplying the params type), so there is no <name>ʗp array to .slice(). (Guarded by the VariadicClosureSpread behavioral test — a single-return closure spreading a... into fmt.Sprintf, a closure ranging its variadic slice, and a single-return closure forwarding a... to another variadic; output-compared vs Go.)

A non-escaping variadic parameter binds through the stack-only sslice<T> view. The C# signature already receives the arguments as params Span<T>, so a prologue whose uses are proven frame-local now emits var xs = xsʗp.sslice(); instead of xsʗp.slice(). The old Span<T>.slice() path calls ToArray(); the stack view removes that allocation and, for a spread call f(s...), keeps the callee aliased to the caller’s backing array as Go requires. The proof is intentionally narrow: direct len/cap, element indexing, and range are eligible. Passing or spreading the slice, assigning or returning it, append, address-taking, slicing, interface boxing, channel/go use, or a nested function literal falls back to the heap slice<T>. When defer/recover requires an execution wrapper, the converter passes the incoming params Span<T> by ref through the one-reference func overload (whose generic reference slot uses C# 13’s allows ref struct anti-constraint) and performs the slice rebinding inside that wrapper. The wrapper therefore does not capture the outer ref-like parameter, and an otherwise-eligible body keeps .sslice() plus Go spread aliasing. Uses that genuinely require a heap slice still fall back to .slice(). Function literals use the same eligibility map and ref-wrapper path. Golib’s cap<T>(in sslice<T>) complements the existing len overload; append deliberately has no sslice grow path, so any append remains a heap case. (Guarded by VariadicPointerParam: safe range/len/cap/index sites emit .sslice(), a spread element replacement is observed through the caller’s slice against go run, defer-wrapper declarations and closures pass the params Span by ref, preserve spread element writes, and nested captures still pin the .slice() fallback.)

The ꓸꓸꓸT alias identifier mirrors the GO name; its referent must be using-independent. The readable params ꓸꓸꓸT form is a namespace-scope C# using alias (using ꓸꓸꓸstring = Span<@string>;), so it applies only where a legal alias identifier exists — everything else falls back to the inline params Span<T>. Two separate constraints decide that, and only the first is about the name. (1) The identifier cannot contain <, > or ., so it transliterates the element’s Go name, joining any package qualifier with TypeAliasDot — Go’s ps ...unsafe.Pointer becomes params ꓸꓸꓸunsafeꓸPointer psʗp, the same pkgꓸType convention package_info.cs already uses for its global usings. Go names are preferred over the emitted C# ones: they need no @ keyword-escape stripping (@ is legal only at identifier start — ꓸꓸꓸ@string is a lex error, CS1002/CS0116 — while the Span<> referent keeps the escape), carry no _package class suffix, and undo a Δ collision-rename, so go/types’ ...Type reads ꓸꓸꓸType rather than ꓸꓸꓸΔType. Any Go identifier is a legal C# one and the ellipsis prefix defuses keywords (...eventꓸꓸꓸevent); types with no Go name — a basic type, a universe type (error), a lifted anonymous struct (internal/fuzz’s CorpusEntry) — transliterate the emitted C# name instead. Because only the name is constrained, a qualified referent still reads like Go: a same-package element is qualified with the package class (a bare nested name like statDep does not resolve at namespace scope, CS0246) yet still emits params ꓸꓸꓸShape shapesʗp over using ꓸꓸꓸShape = Span<main_package.Shape>;. (2) The referent is resolved by C# with the compilation unit’s own using directives not in effect, so it may not name another file-local alias. A cross-package element renders in the short alias form (@unsafe.Pointer, ast.Expr), which is exactly that — left as-is it fails CS0246, and go2cs-gen (which copies these usings into its generated files) cannot resolve the symbol either and falls back to unescaped text, Span<unsafe.Pointer>, whose bare keyword cascades to CS8956. Such a referent is therefore rewritten to the alias’s own target — Span<unsafe_package.Pointer>, Span<global::go.go.ast_package.Expr> — which is using-independent by construction, being what the using <alias> = <target>; line itself resolves. The mapping is recorded where the import using is emitted rather than re-derived, so the -tests package-under-test rebinding is honored automatically; an alias not yet bound when the declaration is visited (visitFile synthesizes canonical aliases for inference-only foreign references after the walk) degrades to the inline form. A pointer element is the one constructed form that does transliterate: go2cs already writes *T as ж<T>, so bs ...*box reads params ꓸꓸꓸжbox bsʗp. Its pointee resolves through the same routine and so takes the same namespace-scope qualification — the alias referent says Span<ж<main_package.box>> where the inline form could say bare ж<box>, since only the inline form sits inside the package class — and the two rules compose, giving go/doc’s ...*ast.File a ꓸꓸꓸжastꓸFile = Span<ж<global::go.go.ast_package.File>>. A pointee with no alias form of its own (a type parameter, a constructed pointee such as *[]byte) takes the whole element inline with it. Genuinely inline-only, then: a type parameter (never in scope inside a referent — First<T>(params Span<T> valsʗp)) and every other constructed element, for which no established transliteration exists (map<@string, any>, slice<byte>, Action<ж<options>>). Two element types transliterating to one identifier in a single file would bind it twice (CS1537), so the first claim wins and the loser stays inline. Deriving from Go names is also what keeps os/signal’s long-standing using ꓸꓸꓸosꓸSignal = Span<osꓸSignal>; and text/template’s ꓸꓸꓸreflectꓸValue byte-identical through this path — they already followed the convention, because their bare C# names come from global usings in package_info.cs, which do carry over into a using-alias referent. (Guarded by the VariadicSlotInterfaces behavioral test — a same-package interface element — and by VariadicPointerParam, which pins all three outcomes in one file: an aliased pointer (ꓸꓸꓸжbox), an aliased cross-package element (ꓸꓸꓸunsafeꓸPointer), and an inline constructed one (Span<slice<byte>>); the type-parameter arm of that fallback is held by GenericVariadicFunc. All output-compared vs Go. Stdlib footprint: every previously-inline site becomes an alias except the type-parameter and non-pointer-constructed ones — same-package elements read bare (ꓸꓸꓸAttr, ꓸꓸꓸtraceArg, ꓸꓸꓸWriter, ꓸꓸꓸType), cross-package ones carry their qualifier (ꓸꓸꓸunsafeꓸPointer in runtime, ꓸꓸꓸastꓸExpr in go/types), and pointers take the ж form — unicode.In(r, ranges ...*RangeTable) becomes params ꓸꓸꓸжRangeTable rangesʗp.)

A named result is DECLARED only when something reads it (2026-08-08). Go’s named results are ordinary addressable locals, so the converter declared every one of them at function entry. But Go names results for documentation far more often than it uses them — func EncodeRune(r rune) (r1, r2 rune) never mentions r1/r2 again — and the emitted rune r1 = default!; is then dead on arrival. That one shape was 1,218 of the corpus’s 1,219 CS0219 (“assigned but its value is never used”) warnings. Nothing is lost by omitting it, because the names survive exactly where a reader reads them — on the C# tuple return type — so the emission moves closer to the Go source, not further from it:

// before
public static (rune r1, rune r2) EncodeRune(rune r) {
    rune r1 = default!;
    rune r2 = default!;

    if (r < surrSelf || r > maxRune) {

// after — the names still read off the signature
public static (rune r1, rune r2) EncodeRune(rune r) {
    if (r < surrSelf || r > maxRune) {

The declaration is kept whenever anything can read it, and the check is deliberately conservative in every unclear case — a retained dead declaration costs one line, a dropped live one is CS0103. It stays when the body references the result (read, assigned, address-taken, or captured by a closure — a capture is a use, so that walk descends into function literals); when the body has a naked return, which reads every named result by definition (that walk stops at a nested *ast.FuncLit, whose bare returns belong to the literal — the iter.Pull shape above depends on this); when the result is heap-box backed, whose box is the storage the render sites reference; and when the function is lowered through either defer form — namedReturnDeferMode, where the declarations sit outside the func() wrapper precisely so deferred closures can mutate them, or a GoFrame, whose named exit emits a trailing return <names>; after the try. In those last two the generated code reads the locals and the Go body need never mention them, so liveness is switched off wholesale rather than inferred. The same rule and the same opt-out apply to function literals (namedReturnDeclLines). Keeping the check rather than suppressing the code corpus-wide also preserves CS0219 as a live signal: a genuinely dropped assignment to a named result still surfaces. (Guarded by namedResultLiveness_test.go, which pins one function per liveness reason plus the dead shape and the outer-dead/inner-naked case, and is proved in BOTH directions by negative control. Corpus effect: CS0219 1,219 → 52, none of them a named-return prologue — 33 are in hand-owned files the converter never re-emits, 18 are Go’s own var witnesses for a constant-folded unsafe.Sizeof/Offsetof, and 1 is a folded local const.)

Slices and Arrays

Go slices and arrays are converted to the golib slice<T> and array<T> structures. A make-style allocation uses a constructor; a composite literal builds a C# array and projects it with the .slice() / .array() extension:

package main

import "fmt"

func main() {
    primes := [6]int{2, 3, 5, 7, 11, 13}   // array literal
    nums := []int{10, 20, 30}              // slice literal
    buf := make([]byte, 4)                 // make
    fmt.Println(primes[0], nums[2], len(buf))
}

converts to:

internal static void Main() {
    var primes = new nint[]{2, 3, 5, 7, 11, 13}.array();
    var nums = new nint[]{10, 20, 30}.slice();
    var buf = new slice<byte>(4);
    fmt.Println(primes[0], nums[2], len(buf));
}

A named slice/array type (type d [3]rune, type s []int) lowers to a struct wrapping array<T>/slice<T>; its composite literal cannot use C# collection-initializer braces (the lowered struct has no Add), so it is constructed through the underlying-collection constructor: d{0, 32, 0}new d(new rune[]{0, 32, 0}.array()).

The empty composite of such a type is its zero value, not a one-element literal: the generic named-composite nil filler (which gives a named struct composite its new T(nil) zero-value ctor argument) previously landed inside the element literal — tmpBuf{} (type tmpBuf [32]byte, runtime string.go’s *buf = tmpBuf{}) emitted new tmpBuf(new byte[]{nil}.array()), a NilType element in a byte[] (CS0029). An empty array composite now emits a zeroed fixed-length backing — new tmpBuf(new byte[32].array()) — because Go’s [N]T{} is a full-length zero array (an empty {} literal would produce a length-0 backing); an empty named-slice composite emits an empty non-nil backing — pm{}new pm(new uint32[]{}.slice()). (Guarded by the NamedArrayWrapper extension — empty array composite read/written at full length, zeroing an existing wrapper through a pointer via *buf = tb{}, and an empty slice composite appended to; values vs Go. The nil-vs-empty distinctionpm{} == nil is false in Go — is a separate pre-existing golib model latent: the slice nil-compare conflates nil with empty-but-allocated.)

Reslicing SHARES the backing array — golib slice<T> stores capacity. A Go reslice is a view adjustment, never a copy: writes through s[a:b] are visible through s (and vice versa), append within capacity writes the shared backing in place, and only append beyond capacity reallocates and detaches. The emitted forms are the C# range indexer for 2-index expressions (s[a:b]s[a..b], s[a:]s[a..], s[:b]s[..b], s[:]s[..]) and the golib .slice(low, high, max) extension for 3-index expressions (s[a:b:c]s.slice(a, b, c); a missing low is the -1 sentinel). Both take bounds relative to the view (the source slice may itself sit at a non-zero offset in its backing array), default a missing high to len(s) — the range indexer resolves a from-end Index against the slice length, so s[1..] is Go’s s[1:] even when len < cap — allow high up to cap(s), and panic Go-style (RuntimeErrorPanic.SliceBoundsOutOfRange) when out of range. To represent a capacity-restricted 3-index view (s[a:b:c], c below the backing array’s end) without copying, slice<T> stores m_capacity as its own field rather than deriving it from the backing array’s length; the slice<T>(T[] array, nint low, nint high, nint max) constructor builds such views. (Historically golib copied on any reslice that didn’t span the whole backing array — a base[2:5] sub-slice was a detached array with lost aliasing — derived capacity from the backing end, and mis-measured Available/Append for non-zero-offset views. Guarded by the SliceAliasing behavioral test — copy into and element writes through a low>0 reslice, reslice-of-reslice offset compounding, slice-of-array reslices, restricted-capacity 3-index writes, in-place vs reallocating append, all read back through the base and value-compared vs Go.)

A make length/capacity/size-hint of a non-int integer type is cast to nint. The golib allocating constructors all take nintslice<T>(nint length, nint capacity), map<K,V>(nint capacity), channel<T>(nint capacity) — and C# does not implicitly convert a uintptr/uint/uint32/uint64/int64 (C# nuint/uint/ulong/long) to nint. So make([]byte, n/goarch.PtrSize) with a uintptr length would leave new slice<byte>(n / …) with no applicable constructor, and overload resolution falls onto slice<T>(T[]) — reported as CS1503 (“cannot convert nuint to byte[]”); a map/chan with a uintptr hint is a direct nuintnint CS1503. The converter casts each such length/capacity/hint argument to nint: new slice<byte>((nint)(n / goarch.PtrSize)) (both args of make([]byte, l, c)), new map<K,V>((nint)(hint)), new channel<T>((nint)(size)). A plain int (nint) and an untyped-constant argument (make([]byte, 4) → a bare 4) bind directly and are left uncast (no golden churn) — as are the widening int8/int16/uint8/uint16 kinds. (Guarded by the MakeSliceUintptrLen behavioral test — uintptr/uint/uint32/uint64 slice lengths, a uintptr len+cap, a uintptr map hint and chan size, and int/untyped controls, all len/cap/element values verified vs Go; runtime hits this in mbitmap’s make([]byte, n/goarch.PtrSize).)

Slicing a pointer-to-array. Go lets a *[N]T be sliced directly — p[lo:hi:max], p[:] — auto-dereferencing the array. The C# box ж<array<T>> has no slice/range members (its underlying array<T> does), so the converter dereferences first: p[1:3:4](~p).slice(1, 3, 4), p[:](~p)[..], p[2:](~p)[2..]. Without the deref the call binds to the box and fails (CS1929). The resulting slice shares the array’s backing storage, matching Go. (The (*[N]T)(ptr)[:n] pointer-cast form is different — see Pointer-cast slice below.) A deref-aliased pointer parameter or receiver is the exception: it is emitted as the pointed-to value, not a box, so a ~ on it would deref a non-pointer (CS0023). When that value is a named array type — b *pageBits emitted ref pageBits b, where pageBits is [N]uint64 — the wrapper has no slice/range members, so its underlying array<T> is reached via .Value: b[:2]b.Value[..2]. When it is an anonymous array (p *[N]Tref array<T> p) the value already is the array<T> and is sliced directly (p[:]p[..]). Only a pointer-to-array box (a local, a field, a call result) gets the ~ deref. (Guarded by the PointerArraySlice behavioral test — local box, named-array receiver, and named-array parameter; runtime hits this in select.go’s cas1[:ncases:ncases] / mprof.go’s stk[:n:n] (locals) and mpallocbits.go’s pageBits receiver methods (clear(b[:])).)

Named-slice pointer reinterpret ((*[][]byte)(buf) with buf *Buffers). Go converts a pointer-to-named-slice to a pointer to its underlying slice type freely — net’s fd.pfd.Writev((*[][]byte)(buf)), where poll’s Writev reslices the header through the pointer (consume advances *v), and the caller must observe it. The C# boxes ж<Buffers> and ж<slice<slice<byte>>> are unrelated generic instantiations (CS0030), so the conversion emits a field view over the wrapper’s own backing slice:

consume(buf.of(Buffers.m_value));

Two generator pieces make the view real aliasing: a named-slice wrapper’s m_value is mutable (ReadOnlyValue = false — a readonly field would force a defensive copy and lose header writes), and ISliceTypeTemplate emits the field-ref accessor internal static ref slice<T> Ꮡm_value(ref Buffers instance) that ж<T>.of() projects through. Claimed narrowly: pointer→pointer, source pointee a NAMED type whose underlying is a slice identical to the (unnamed) target pointee. (Guarded by the SortArrayType extension consumeOne — a (*[]Person)(&crew) reinterpret whose reslice through the view shrinks the original Roster, runtime-verified against Go.)

The reverse direction — an underlying-slice pointer to a NAMED-slice pointer, (*Buffer)(&b) with type Buffer []byte (log/slog/internal/buffer’s sync.Pool.New) — is asymmetric, because the projection above cannot run backwards: a named-wrapper box contains the underlying slice (project it out), but a bare-slice box does not contain a wrapper to project. It is emitted as golib’s storage reinterpret instead — Ꮡb.Reinterpret<slice<byte>, Buffer>() — which re-views the same slot as the wrapper type rather than constructing anything: a generated named-slice wrapper is a single-field struct over the slice header, exactly the layout correspondence ReinterpretAliasesStorage recognizes, so the managed alias arm engages and the derived pointer ALIASES the addressed slice. A bare (ж<Buffer>)(Ꮡ(b)) cast is CS0030 (unrelated instantiations). The source comes two ways and both render in BOX form (the isPointer ident context): an address-of arg (&b, &h.field) and an existing pointer arg (cryptobyte’s (*String)(out) with out *[]byte). Claimed narrowly by the mirror gate: pointer→pointer, target pointee a NAMED type whose underlying is a slice identical to the (unnamed) source pointee.

It previously constructed a wrapper box over a copyᏑ(new Buffer(b)) — on the stated assumption that such a reinterpret is only ever used through the returned pointer. That assumption is false wherever the conversion exists precisely to write BACK, which is the shape’s dominant corpus use, and the writes went nowhere:

Site What the copy cost
log/slog commonHandler.withAttrs(*buffer.Buffer)(&h2.preformattedAttrs) Every attribute WithAttrs pre-formatted was dropped, while the handler still advanced groupPrefix/nOpenGroups — so the JSON it emitted afterwards was unbalanced. Four testing/slogtest rows (WithAttrs, multi-With, empty-group-record, resolve-WithAttrs).
crypto/tls readUint{8,16,24}LengthPrefixed(*cryptobyte.String)(out) with out *[]byte An out-PARAMETER whose whole purpose is the write-back: the caller’s slice never received the length-prefixed field.
crypto/tls parseECHConfigList(*cryptobyte.String)(&ec.PublicKey) A struct FIELD that stayed empty after a successful parse.
vendor/…/cryptobyte ReadASN1Bytes(*String)(out) Same out-parameter shape, same loss.

Corpus A/B footprint of the correction: 5 files, 8 sites (log/slog/handler.cs, log/slog/internal/buffer/buffer.cs, crypto/tls/ech.cs ×2, crypto/tls/handshake_messages.cs ×3, vendor/golang.org/x/crypto/cryptobyte/asn1.cs), plus the dead ref var @out = ref Ꮡout.DerefOrNull() locals the aliasing form no longer needs. (Guarded by the NamedSlicePointerReinterpret behavioral test — a direct (*Buf)(&b) read back through b, the closure-returned func() *Buf { … return (*Buf)(&s) } shape, a pointer-parameter fillVia(out *[]byte) read back through out, and a struct-field (*Buf)(&h.preformatted) appended across a reallocating growth and then truncated through a freshly derived pointer — output-compared vs Go.)

Pointer-cast slice ((*[N]T)(ptr)[:n]). A Go conversion that casts an unsafe.Pointer to a pointer-to-array and slices it produces a []T over the pointed-to memory. It is emitted as the golib slice<T> — the C# representation of every []T — built from a ReadOnlySpan<T> over the raw pointer: new slice<T>(new ReadOnlySpan<T>((T*)ptr, (int)n)). (Earlier it was a bare Span<T>, but a Span<T> does not range as (index, element) tuples — for i := range s → CS8130 — and has no Ꮡ(s, i) element-address — CS0411; slice<T> supports both, since it is IArray<T>.) The ReadOnlySpan<T> constructor takes a C# int, so a Go int/uint length (nint/nuint) is narrowed via getRangeIndexer (through the underlying for a named numeric); an int literal is left as-is. The slice copies the pointed-to memory (ReadOnlySpan.ToArray()), which is self-consistent for code that only uses the resulting slice (e.g. runtime’s printDebugLog ranges state and writes &state[i], never re-reading the raw buffer; os_windows ranges an unsafe []byte read-only). Since this is always the (*[N]T)(ptr) unsafe-cast form, it is memory-layout-dependent code whose raw values flow through the unsafe.Pointer=nuint round-trip (a transient fixed address → not GC-stable), so the runtime values are not the contract — only compilable, rangeable, element-addressable C#. (Guarded by the PointerCastSliceRange behavioral Compile + target test — index range, value range, and &s[i] element-address over a pointer-cast slice; runtime greened debuglog’s printDebugLog and os_windows, ~25 errors via the cascade. The length narrowing is covered by StdLibInternalAbi.)

An untyped (type-inferred) composite literal — the inner {…} of a [][]rank{ key: {…} }, which has no explicit type node — is emitted as a target-typed new(…) when its inferred type is a struct (the struct constructor takes the field values). When the inferred type is a slice or array, that form is wrong (slice<rank>/array<rank> have no element-list constructor → CS1729); the converter emits the element-array projection instead — {rA, rB} (inferred []rank) → new rank[]{rA, rB}.slice(), and an inferred [2]intnew nint[]{…}.array(). When the inferred type is a pointer-to-struct — the []*T{ {…} } shorthand for &T{…} — it is emitted as the boxed struct constructor Ꮡ(new T(field: val, …)) (a bare new(…) would target the box ж<T>, whose constructor lacks the struct’s fields → CS1739). When such an untyped slice/array literal is keyed ({joiningL: stateBefore, …} — the inner {…} of x/net/idna’s joinStates = [][numJoinTypes]joinState{stateStart: {…}, …}), the element-array projection above cannot take Go’s key: value syntax — new joinState[]{ joiningL: stateBefore } is a C# array initializer, which has no keyed element form (CS1003 ×62). The keyed case is routed to a golib golib.SparseArray<T> collection initializer instead — new golib.SparseArray<joinState>{ [joiningL] = stateBefore, … }.array() (.slice() for a slice element) — the same form the typed keyed slice/array path emits (see below); the .array()/.slice() IEnumerable<T> extension materializes the dense backing, and a defined-integer key takes the [(int)key] cast exactly as in the typed path. (Guarded by UntypedNestedSliceComposite; runtime/lockrank.go’s lockPartialOrder is a [][]lockRank and runtime1.go’s dbgvars is a []*dbgVar of the positional forms, and x/net/idna’s joinStates is the keyed form.)

An indexed (keyed) slice/array literal[]string{lockRankSysmon: "sysmon", …} — is emitted as a golib golib.SparseArray<T> collection initializer ([index] = value). Its indexer takes a Go int. When an index key’s Go type is a defined integer type whose underlying type does not implicitly widen to C# int (i.e. int/int64/uint/uint32/uint64/uintptr, as opposed to int8/uint8/int16/uint16/int32), the key is cast to int so it satisfies the indexer (CS1503 otherwise): [lockRankSysmon] (a type lockRank int) → [(int)lockRankSysmon]. A key that already widens (e.g. a uint8-backed Kind) is left uncast.

A keyed slice/array literal whose element type is a non-empty INTERFACE routes its elements through the interface-cast element loop (each value wraps via convertToInterfaceType) instead of convExprList — and that loop rendered a KeyValueExpr as a FLAT key, value pair, feeding the SparseArray collection initializer one item at a time (Add(key) then Add(value): CS1950 + CS1503 ×21 pairs on go/internal/gccgoimporter’s lookupBuiltinType, [...]types.Type{gccgoBuiltinINT8: types.Typ[types.Int8], …} — untyped named-const keys in a function-body literal, immediately indexed). The loop now emits the same [key] = wrappedValue indexer form the non-interface path produces, with the key routed through sparseArrayKey (so a defined-integer-type key keeps its [(int)key] cast alongside the interface-wrapped value); a keyed MAP literal with an interface element type reaching the same loop takes the identical indexer form. (Guarded by the SparseArrayIfaceElem behavioral test — a function-body sparse literal with untyped named-const keys immediately indexed, the package-level form, and a named-uint-keyed form, elements read back and output-compared vs Go.)

A keyed (sparse, constant-index) literal of a named array-wrapper type — internal/trace/oldtrace’s timedEventArgs{1: uint64(ev.StkID)} where type timedEventArgs [4]uint64 — backs onto the golib array<T>(length) (which has an indexer setter), not a raw C# array. The wrapper’s constructor takes an array<T> (the positional path already produces one via .array()), and the keyed elements render as the [i] = v indexed initializer — valid on new array<uint64>(4){[1] = v} but not on new uint64[]{[1] = v} (CS0131, an array-initializer takes no indexed members). A positional literal of the same wrapper keeps the new uint64[]{…}.array() form (unchanged — no churn). (Guarded by NamedArrayKeyedLiteral — a type args [4]uint64 with multi-keyed, single-keyed, and positional literals, element reads output-compared vs Go.)

A generic named array type carries its type parameters (and their constraints) onto the forward declaration, and its element type is emitted fully qualified in the [GoType] attribute so the generated array-backed partial — which lives in a file without this file’s package-relative using aliases — can resolve it:

type table[T any] [3]atomic.Pointer[T]
[GoType("[3]sync.atomic_package.Pointer<T>")] partial struct table<T>
    where T : new();

An anonymous array/slice field whose element type lives in a multi-segment-path packagecpuLogWrite [2]atomic.Pointer[profBuf], children [4]atomic.UnsafePointer (atomic = internal/runtime/atomic) — keeps its array<…> wrapper. The field’s type name is built structurally from the [N]/[] marker plus the recursively resolved element, not from the type’s package-qualified string: that string ([2]internal/runtime/atomic.Pointer[…]) goes through a cross-package last-segment strip that would also remove the leading [2], collapsing the field to the bare element type (atomic.Pointer<…> = new(2)) whose array new(2) initializer then mis-binds the element constructor (CS1503). With the structural rendering the field stays array<atomic.Pointer<profBuf>> = new(2). An array of a current-package or basic-typed element was unaffected (its string has no foreign path to strip). (Guarded by the ArrayOfCrossPackageType behavioral test — [3]atomic.Int32 / [2]atomic.Uint64 fields; runtime’s trace/traceMap structs hold these.)

A struct’s array fields get their fixed length from a generated parameterless constructor

A Go [N]T array FIELD has a zero value of N zero elements — never nil. The converter emits the field with a length initializer — internal array<atomic.Int32> c = new(3); — but a C# struct field initializer only runs when an explicitly declared parameterless constructor is invoked; the implicit struct constructor that new counters() would otherwise use zeroes every field and SKIPS initializers, leaving the array’s backing T[] null (an NPE on the first index or len). The TypeGenerator therefore emits an explicit parameterless constructor for every struct, so new S() runs the field initializers and each array field gets its new(N) backing. (C# 11 auto-defaults any field without an initializer; a slice/map/chan field — which has no new(N) initializer — stays its nil zero value, matching Go.) The NilType constructor preserves the initializers too: it used to re-assign this.field = default! to every plain member — running after the field initializers, which nulled an array field’s fresh new(N) backing, so S{} (emitted new S(nil)) NREd on the first index. The NilType and parameterless constructor bodies (AppendZeroValueInitializers) now assign only what C#’s implicit zeroing would leave broken: the promoted-embed boxes (see Struct Type Embedding), and — see next paragraph — any plain struct-typed field whose own type needs construction; everything else is left to the field initializers plus C# 11 auto-default. This is generator-only and produces no golden churn (the .g.cs output is not a golden). It is what lets ArrayOfCrossPackageType run as an output-compared test (len(x.c) / len(x.d) print 3 2); before the fix, indexing &x.c[i] threw a NullReferenceException, so the test was compile+target-only.

A plain (non-embed) struct-typed FIELD whose type itself needs construction is the recursive case: default(T) gives such a field a default(FieldType), whose nested promoted-embed box or fixed-array backing is null — so the first touch NREs even though T’s own boxes were constructed. This is fmt’s pp{ … fmt fmt … } where fmt embeds fmtFlags (a ctor-allocated box): newPrinter’s @new<pp>() ran pp()’s ctor, which left fmt as default(fmt) with a null box, and p.fmt.init(&p.buf)clearflags NREd — the first crash of any converted fmt.Println. AppendZeroValueInitializers therefore also emits this.f = new FieldType(nil); for each such field, and because that runs FieldType’s own NilType constructor (which recursively constructs its needy fields), a single level of construction fixes every depth. “Needs construction” (StructTypeNeedsConstruction) is: has a promoted embed, a fixed-array field, or a nested struct field that needs construction; a reference field (pointer/interface/delegate) keeps its correct nil zero value.

The resolution must be by SYMBOL, not by syntax. StructTypeNeedsConstruction originally answered only for structs it could find a StructDeclarationSyntax for (GetStructDeclaration), and left every other field type default on the reasoning that “its own package constructs it, and its nil zero value is correct anyway”. The first half is wrong and the second half does not apply to a fixed array. A <ProjectReference> reaches the compiler as a PortableExecutableReference — compiled metadata with no syntax trees — so in any real MSBuild build every cross-package field type was unresolvable, and nothing in the consuming package ever constructed it. When such a type carries a fixed array at any depth, default leaves that array<T>’s backing null (golib’s deliberate zero-value discriminator), so len and range silently measure zero and the first index or pin throws — GCHandle.AddrOfPinnedObject’s InvalidOperationException: Handle is not initialized (see golib ж.cs, pinnedArrayData). The live case was math/rand/v2’s ChaCha8, whose internal chacha8rand.State state; never got State’s buf = new(32) / seed = new(4), where Go’s new(ChaCha8) yields 32 real zeroed words. (The syntax path only ever worked because CompilationReferences — in-memory Roslyn compilations — do carry syntax; that is the shape unit tests use, not the shape MSBuild produces.)

GetStructDeclaration is therefore backed by Compilation.FindTypeSymbol, which resolves a fully-qualified display name to an INamedTypeSymbol through GetTypeByMetadataName (stripping global:: and verbatim @, rendering type arguments as arity suffixes, and trying each namespace-vs-nested-type split of the dotted name since a display string spells both .). The metadata walk applies the same three triggers over GetMembers() — a ref-returning property is a promoted embed, a go.array<T>-typed field is a fixed array, a struct-typed field recurses — with the same cycle guard. On the metadata path the public T(NilType) constructor that new T(nil) needs is checked rather than assumed (metadata is fully compiled, so the generated constructor is really there): a hand-written golib struct or any other referenced type without one returns false and correctly keeps its default. Scalars, pointers, slices, maps and interfaces are still left default, because default is their Go zero value — over- constructing would add an allocation to every instantiation for no semantic gain. (Guarded by the CrossPackageArrayZeroValue output-compared test — a Holder whose field type lives in the bufpkg sibling library sub-project, so the reference is genuinely metadata; a same-project field type resolves by syntax and would pass even unfixed. Against the unfixed generator the test panics with index out of range [2] with length 0.)

The field-wise constructor closes the same gap for a PARTIAL composite literal. The parameterized constructor (GenerateConstructor, used by a composite literal that sets some fields — &Holder{tag: "lit"}new Holder(tag: "lit")) took T f = default! for every member and assigned this.f = f; unconditionally, so an OMITTED needy-struct argument arrived as the broken default(T) and overwrote the field — identical breakage to the NilType path above, and (because it never consulted StructTypeNeedsConstruction) firing for a same-package field type too. The live case is io.pipe, whose onceError rerr, werr value fields each embed sync.Mutex via the promotion box: io.Pipe() builds the pipe with new pipe(wrCh: …, rdCh: …, done: …), omitting rerr/werr, so both boxes were null and the first Store/Load Lock() NREd on the pipe’s writer goroutine — crashing every io.Pipe consumer (encoding/base32’s TestBufferedDecodingPadding; the goroutine NRE aborted the whole test host). A fixed-array member beside it already had the analogous if (f.Source is not null) guard (its = new(N) field initializer supplies the omitted zero); the needy-struct member has no field initializer to fall back on, so it must be constructed. GenerateConstructor now emits the needy value-struct member’s parameter as nullableonceError? rerr = default! — making an omitted argument a genuine null sentinel that default(onceError) (a real struct value with a null box) could never be — and its body reconstructs only when omitted: this.rerr = rerr ?? new onceError(nil);, exactly mirroring the pointer-embed ?? new ж<T>(nil) handling for a promoted embed. A caller-SUPPLIED value is used as-is (no extra allocation, unchanged reference semantics — the struct copy shares the same embed box); an omitted one gets T’s own NilType ctor, which recursively constructs its needy members. The predicate IsNeedyValueStructMember reuses StructTypeNeedsConstruction (member is not a promoted embed, not a reference, not a fixed array, and its struct type needs construction), so every ordinary member’s parameter/assignment is byte-identical to before — the change is confined to genuinely-needy value-struct fields. All of the NilType, parameterless, and now field-wise constructors are covered, so this reaches new(T)/@new<T>()/T{}/&T{…} (empty and partial composite literals). (Guarded by the PromotedEmbedZeroValueField output-compared test — a slotBox{id: 3} partial literal omitting a holder value field that embeds a promoted counter, whose promoted inc() is then called on the omitted field; against the unfixed generator it NREs with Object reference not set to an instance of an object, exactly as encoding/base32 did.)

A bare var x T zero-value declaration (no initializer) calls none of those, so the converter closes the remaining gap on its side: when T needs construction it emits T x = new(); — the generated parameterless constructor, which runs the same field initializers + AppendZeroValueInitializers — instead of the T x = default!; that left an array field’s backing null (an NRE on the first index/len). The converter mirrors StructTypeNeedsConstruction with the Go-side structZeroValueNeedsConstruction (promoted embed / fixed-array field / nested needy struct, recursively; a reference field keeps its correct nil zero value). A promoted-embed var keeps its existing new(nil) (the NilType ctor) and a scalar-only struct keeps default!, so the change is confined to genuinely-needy structs — one pre-existing corpus golden re-baselined, PublicizedFieldType’s var cr CaseRange (a [3]rune Delta field). A needy struct global likewise gets new() in place of the bare static T x;. This var/global path is guarded by the ZeroValueStructVar output-compared test (var z holder with a [8]int field, a nested wrapper, and a scalar-only point control). Guarded by the NestedPromotedEmbedInit output-compared test (a printer holding a formatter field that embeds flags and holds a [3]byte, reached via both new(printer) and &printer{}, its promoted fields and array written and printed against Go); before the fix the promoted-field write NRE’d.

Prefer the file-local package alias over the fully-qualified _package name

A cross-package named type has two C# spellings: the fully-qualified form sync.atomic_package.Int32 (the namespace-rooted class, from getFullyQualifiedTypeName) and the file-local alias form atomic.Int32 (the using atomic = sync.atomic_package; shorthand, from getAliasQualifiedTypeName). For visual fidelity — the converted C# should read like the Go original, which writes atomic.Int32 — body emission prefers the alias. But the alias is only resolvable where the using is in scope, so the choice is made per emission site by getScopeCheckedTypeName, which returns the alias form only when every cross-package type referenced by the type is imported in the current file (checked against the per-file importQueue), and otherwise falls back to the fully-qualified form.

The fallback matters: a Go file may index an atomic-typed array field of a struct — &x.c[i]…at<E>(i) — without ever naming the element type E, so it carries no import "sync/atomic" and no using atomic. There the element type must stay fully-qualified (it resolves inside namespace go; with no alias) or the file fails CS0246. When the package is imported (the common case, and every behavioral test of this), the prettier alias is used.

getScopeCheckedTypeName is applied at the body-emission sites that land in the current source file:

It is not used for forms consumed by the source generators in alias-less generated files, which must stay fully-qualified: the [GoType("…")] attribute string (e.g. [GoType("sync.atomic_package.Uint32")], [GoType("[3]sync.atomic_package.Pointer<T>")]), the global using type-alias declarations, and the promoted-interface/embedded-field registration keys. (Embedded fields keep the full form for their promoted accessors; only the named-field branch uses the display name. Struct-embedding promotion across packages re-derives member types from the Roslyn semantic model, not from the field’s emitted text, so aliasing the field declaration is safe.) Guarded by ArrayOfCrossPackageType, AtomicValues, FuncTypeParam, GenericAtomicPointerField, GlobalAtomicDefer, GlobalAtomicFieldMethod, and StructPromotionWithInterface/StructPointerPromotionWithInterface.

Combined field-element address base.at(field, i)

The address of an element of an array/slice FIELD of a boxed value — &x.c[i] where c is an array field, or the implicit address taken to call a pointer-receiver method x.c[i].inc() — was rendered as a two-step chain Ꮡx.of(counters.Ꮡc).at<atomic.Int32>(i): of(field) takes the field’s address (a ж<array<E>>), then at<E>(i) takes the element’s. The explicit <E> is needed because golib’s standalone at<TElem>(nint) is generic in an element type unrelated to the pointer’s T, so it cannot be inferred. golib adds combined overloads ж<T>.at<TElem>(FieldRefFunc<…array<TElem>…>, nint index) (one per field-accessor shape and array/slice kind, each forwarding to of(field).at<TElem>(i)) whose TElem IS inferred from the field accessor’s return type. The converter then collapses the chain to Ꮡx.at(counters.Ꮡc, i) — dropping both the .of( step and the <E> type argument. It rewrites the recursively-built field address base.of(Type.Ꮡfield) by retargeting its trailing .of(field) to .at(field, i), only when the field segment is parenthesis-free (a plain Type.Ꮡfield accessor, so the final ) provably matches the last .of(); any other shape falls back to the explicit chained form. The combined overload is behaviorally identical to the chain (it literally forwards to it). (Guarded by ArrayOfCrossPackageType, IndexedElementDirectBoxMethod and PointerFieldArrayElementAddress — all output-compared; the .inc()/bump() element writes verify runtime equivalence.)

The routing gate sees through nested value fields to the chain root. &pp.wbBuf.buf[0] (runtime mwbbuf.go) roots at the pointer pp through the value field wbBuf; the original gate checked pointer-ness only one level up (pp.wbBuf, a struct), fell to a naive prefix (Ꮡpp.wbBuf… — CS1061 on the box), and the same failure hit the closure-captured variant (&mp.trace.buf[gen%2], trace.go). The gate now walks intermediate selectors to the root, so any pointer-rooted (or heap-boxed) chain routes through the recursive &field machinery — pp.of(pstate.ᏑwbBuf).at(wbBuf.Ꮡbuf, 0) — which already rendered multi-hop of-chains. A nested-index base — &cache.entries[ck][i] (2-D array via a pointer, symtab.go) — is an IndexExpr, not a selector, so it gets its own arm: recursively take the inner element’s address (cache.at(pcvalueCache.Ꮡentries, ck)) and chain the outer .at<T>(i) onto it — the gate also accepts a HEAP-BOXED value root (&grid.cells[1][2] on an address-escaping local), fixing that shape too. An unboxed value-rooted chain keeps the prior naive form (corpus byte-identical). Known remaining gap (pre-existing): an intermediate IndexExpr inside the selector chain — &ptr.items[i].buf[j], an array-of-structs hop — defeats the root walk (both arms only step through selectors) and keeps the CS1061 naive form; the recursive machinery likely has the pieces when a runtime site demands it. (Guarded by the NestedFieldElementAddr behavioral test — all three runtime shapes with write-through vs Go; note a ZERO-VALUED struct’s array-field backing is null in the C# emulation — a separate pre-existing latent — so the test initializes its arrays.)

Element address of a by-value ARRAY PARAMETER. Array parameters are cloned by value in the function preamble (value = value.Clone();, Go’s array-copy semantics) but are never escape-analyzed, so they have no heap box — the naive element-address form would name a box that does not exist (Ꮡvalue.at<byte>(0), CS0103 — syscall SetsockoptInet4Addr, &value[0] on value [4]byte). The converter boxes a copy of the wrapper struct instead: Ꮡ(value).at<byte>(0). array<T> wraps a T[] reference, so the copied wrapper SHARES element storage with the cloned parameter — element reads and writes through the pointer stay behaviorally correct. (One accepted edge, no stdlib hit: reassigning the whole array param after taking an element address leaves the pointer on the older backing array.) (Guarded by DeferTypelessReturnsfirst — element address of a [4]byte parameter, value vs Go.)

Element address of a POINTER-to-array — &t[i] where t is *[N]E. Go auto-derefs the index ((*t)[i]), so the element lives in the pointed-to array on the heap; t already IS the ж<[N]E> box. The converter emits t.at<E>(i) — ж’s at materializes the array’s lazy backing on the REAL storage (a non-boxing constrained interface call) and then returns an element pointer over the shared backing. The base is rendered in POINTER context so it yields the box: a deref-aliased pointer PARAMETER gives Ꮡt (the parameter is ж<[N]E> Ꮡt, deref-aliased to ref var t = ref Ꮡt.Value in the prologue), while a box-valued LOCAL from new([N]E) gives the plain t. Previously this shape fell through every array/slice branch (the base’s type is a *types.Pointer, not an array or slice) to the generic Ꮡ(t.Value[i]) copy form, which boxes a snapshot of the element and silently drops any write made through the returned pointer. This is exactly hash/crc32’s slicingMakeTable/simpleMakeTable: simplePopulateTable(poly, &t[0]) populated a throwaway copy, leaving every CRC table all-zeros (checksums degenerated to ~0-with-shifts — TestGolden, TestSlicing). The same latent write-through-a-copy bug lurked corpus-wide wherever &ptr[i] on a pointer-to-array was written through — crypto/internal/nistec (p224GG[i].SetBytes(…) in static init), internal/bisect and runtime (atomic.Store*(&arr[i], …)) — all now alias correctly. (A pointer-to-SLICE cannot reach here: Go does not auto-deref *[]E for indexing; it is written (*t)[i], a StarExpr the slice branch already aliases.) (Guarded by PointerToArrayElementAddress — write-through &g[j] on a *grid local, value vs Go.)

Element address of a SLICE FIELD of the receiver — &b.lines[i]. The address of a slice element uses one of two golib forms: the element-aliasing two-arg Ꮡ(x, i) (→ new ж<T>(IArray, index), whose ValueSlot returns ref backingArray[index], so writes land in the shared backing array), or the copy-boxing Ꮡ(x[i]) (→ Ꮡ(in T), which boxes a copy of the element value). For a slice the copy form is only sound when nothing is written back through the pointer. Inside a pointer-receiver method the converter had a refRecv fast-path that chose the copy form for a “receiver reference to a slice” — but its detection keyed off getIdentifier(indexExpr.X), which walks the selector chain to its root identifier. So a slice field of the receiver — &b.lines[i], whose base b.lines roots at the receiver b — matched the fast-path too, and emitted the copy form. text/tabwriter’s terminateCell does line := &b.lines[len-1]; *line = append(*line, cell): the append grew a copy of the row’s slice header and wrote the new length into the boxed copy, never back into b.lines, so every line stayed length 0 and all formatted output came out empty (only the newlines survived). The fix restricts the copy form to the case the receiver is directly the slice (indexExpr.X is the bare receiver identifier); any slice base that is a field, call result, or other non-identifier expression uses the element-aliasing Ꮡ(x, i) form — which is correct for a slice in all cases, since a slice value always shares its backing array. This also corrected a benign read-only site (NamedFuncTypeStructuralField’s s.by(&s.items[j], &s.items[i]) comparison) from copy to alias. (Guarded by SliceFieldElementAddress — append-through-pointer into a [][]int field of a pointer receiver plus an in-place element mutate, value vs Go; validated end-to-end by text/tabwriter’s test suite.) The ARRAY branch carried the identical defect and is narrowed the same way — next.

Element address of an ARRAY FIELD of the receiver — &d.hashHead[h]. The array branch had its own refRecv fast-path with the same root-identifier detection, and so the same bug: an array field of the receiver roots at the receiver and took the copy-boxing Ꮡ(d.hashHead[h]), whose Ꮡ(in T) overload heap-boxes a copy of the element. compress/flate’s deflate() is the canonical victim — it does hh := &d.hashHead[hash&hashMask]; … *hh = uint32(d.index + d.hashOffset) to maintain the chained hash table. Every head write landed in a throwaway box, so hashHead stayed all-zero, d.chainHead was always 0, and the d.chainHead-d.hashOffset >= minIndex guard (0-1 >= 0) meant findMatch was never called at all. Levels 2–9 therefore emitted LITERALS ONLY: still-valid deflate streams roughly the size of HuffmanOnly output. Since png.BestCompression maps to flate level 9, a 256×256 PNG encoded to 134,644 bytes instead of Go’s 36,760 — pixel-identical on decode, ~3.7× weaker compression, with NoCompression, HuffmanOnly and BestSpeed (whose deflatefast.go encoder writes e.table[…] directly and never takes an element address) all byte-exact, which is what localized it. The fix mirrors the slice branch: the copy form is kept only when the receiver is directly the array (indexExpr.X is the bare receiver identifier); an array field of the receiver uses the element-aliasing two-arg Ꮡ(d.hashHead, (int)(…)).

Note the array field deliberately does not route through the .of(field)/.at<T>(i) box machinery described above, even though that machinery exists for array fields. Its trigger is baseIsPointer — the Go receiver type is *T — but a Go pointer receiver renders as this ref T recv, which has no box companion, so it emitted Ꮡr.of(RegArgs.ᏑInts) for internal/abi’s &r.Ints[reg] → CS0103. The two-arg form needs no box and aliases correctly regardless: array<T> is a readonly struct wrapping an eagerly allocated T[], so evaluating the field copies only the wrapper while the copy shares element storage — the same reasoning the array-parameter case above relies on. Seventeen corpus files corrected, several of them silently broken in the same write-dropping way: runtime’s &r.statusTraced[gen%3], &h.counts[…] and &m.stats[gen] performed .CompareAndSwap/.Store/.Add on a copy; crypto/internal/edwards25519 built its lookup tables via (&v.points[i]).FromP3(…) into copies; image/jpeg wrote Huffman/quantization tables through &d.huff[tc][th] and &d.quant[…]. (Guarded by RecvArrayFieldElementAddress — chained-hash write-through with an unsigned index, plus a nested &h.pairs[i][j], value vs Go; the flate ratio itself is verified by deflating fixed buffers at every level and byte-comparing the sizes against go run.)

Array ASSIGNMENT copies the whole array (.Clone() on the RHS)

Go array assignment copies the array — data := ints yields independent storage — but the emitted array<T> is a struct over a shared T[] backing store, so a plain C# struct copy aliases: a write through the copy was visible through the source. The first operational hit was sort’s TestReverseSortIntSlice (data := ints; data1 := ints left ONE store sorted ascending then descending, so the ascending/descending mirror check failed ×7 — misdiagnosed at first as an embed-override dispatch defect; the dispatch was correct). The converter now appends golib’s strongly-typed array<T>.Clone() to an assignment RHS that copies an array out of existing storage (see cloneArrayValueCopy in visitAssignStmt.go):

d := garr        // → var d = garr.Clone();
var e = garr     // → array<nint> e = garr.Clone();
e = src          // → e = src.Clone();
f := h.arr       // selector RHS      → var f = h.arr.Clone();
row := m[1]      // index RHS         → var row = m[1].Clone();
g := *q          // deref RHS         → var g = q.Value.Clone();
x, y = y, x      // tuple swap        → (x, y) = (y.Clone(), x.Clone());

Only existing storage takes the clone — an ident, selector, index, or deref RHS reads a value some other name can still reach; a composite literal, call result, or conversion is freshly constructed and stays bare. The shape/type gate is the shared exprReadsArrayValueFromStorage (arrayCloneOperations.go), which tests the UNDERLYING type — so direct, alias-declared, and NAMED array types all clone (the named wrapper via its strongly-typed Clone(), next section), and an interface-typed LHS (var x any = arr) boxes the clone. (Guarded by the ArrayPassByValue extension — all seven assignment shapes above, written-through and read back against the source — and ArrayValueCopySitesnamedAssignCopies — named :=/var/any-boxed forms, values vs Go.) The other copy sites of the same defect class — range elements, composite-literal elements, returns, channel sends, append elements — are covered by the follow-up section below.

Array VALUE-COPY at every transfer site (range, composite, return, send, append) — DEEP for nested arrays

Go copies the whole array at every value transfer, not just assignment and parameter passing — but the emitted array<T> (and the generated named-array wrapper) is a struct over a shared T[] backing store, so every plain C# struct copy ALIASES. The converter appends the strongly-typed .Clone() wherever an array value is read out of existing storage (an ident, selector, index, or pointer-deref — exprReadsArrayValueFromStorage in arrayCloneOperations.go; a composite literal or call result is freshly constructed and needs none):

for _, row := range m { row[0] = 99 }  // → foreach (var (_, vᴛ1) in m) { var row = vᴛ1.Clone(); … }
for i, row = range m {  }             // pre-existing vars → row = vᴛ1.Clone(); inside the body
m := [2][3]int{a, b}                   // → new array<nint>[]{a.Clone(), b.Clone()}.array()
s1 := holder{arr: a}                   // keyed struct field  → new holder(arr: a.Clone())
mv := map[string][3]int{"x": a}        // map value           → ["x"u8] = a.Clone()
mk := map[[2]int]string{k: "kv"}       // map KEY             → [k.Clone()] = "kv"u8
lst := []any{b}                        // interface boxing    → new any[]{b.Clone()}.slice()
return h.arr                           // return              → return h.arr.Clone();
ch <- a                                // channel send        → ch.ᐸꟷ(a.Clone())
s = append(s, a)                       // append element      → s = append(s, a.Clone())

The range emission routes an array-valued key/value through the same iterate-a-temp mechanism a reassigned range var uses (a C# foreach variable cannot be redeclared from itself); a map range KEY of array type clones the same way. The RECEIVE side of a channel needs no twin — the send stored an unaliased element and a buffered element is dequeued exactly once.

Three deeper repairs make the single .Clone() correct everywhere:

The suffix must be WRAPPED on a ~-prefixed rendering. A deref whose operand is a pointer CAST (*(*T)(p), convStarExpr’s casted-pointer-deref path) renders with the PREFIX ~ operator, and C# postfix binds tighter than unary — so a naked .Clone() re-binds onto the cast’s inner operand instead of the dereferenced array. reflect’s InterfaceData is the real-world case:

return *(*[2]uintptr)(v.ptr)   // reflect/value.go
return ~(ж<array<uintptr>>)(uintptr)(v.ptr).Clone();    // WRONG — .Clone() reads v.ptr (CS1061)
return (~(ж<array<uintptr>>)(uintptr)(v.ptr)).Clone();  // emitted — clone the dereferenced array

Every clone-append site therefore routes its rendering through appendArrayValueClone (arrayCloneOperations.go), which wraps only when the rendering starts with the deref operator — the same precedence guard convStarExpr already applies when IT appends the postfix .Value to a cast/deref rendering. Every other shape exprReadsArrayValueFromStorage admits (ident, selector, index, and the postfix .Value deref form) is already a C# primary expression, so the change is byte-neutral wherever the suffix was correct — the corpus-wide A/B footprint was exactly this one reflect line, whose CS1061 had blocked the whole converted stdlib through fmtreflect.

(All guarded by the ArrayValueCopySites behavioral test — one output-compared section per site class, including multidimensional deep-copy through range and parameter passing — plus ArrayCastDerefClone, which guards the wrapped cast-deref form above. That guard is now output-compared: its TYPED-pointer half (*(*T)(p) where p is already *T) RUNS once the identity reinterpret stops routing through the raw-address uintptr bridge (see A SAME-TYPE reinterpret … collapses to the pointer itself), so the clone’s copy semantics are proven by VALUE — mutating the returned array must leave the pointed-to original untouched, and the lvalue form must write through. Its unsafe.Pointer half stays compile-shape only, with the results deliberately discarded: reconstructing an array through an unsafe.Pointer round trip reads raw memory and cannot reproduce Go’s values under the managed model.)

Known remaining gaps (documented, not yet emitted): (1) golib-internal element-wise transfers of nested-array elements (copy(dst, src), spread append(dst, src...)) copy element structs without re-cloning; (2) an array-typed map KEY at an index-STORE (mk[k] = v stores k uncloned — only the composite-literal key form clones); (3) a named↔underlying array CONVERSION ([4]int(named)) hands the wrapper’s backing through the implicit operator uncloned; (4) an EMBEDDED struct member is held as a ж<T> box, so a struct copy shares the embed outright (b := a; b.n = 99 writes through to a.n) — a defect of the embed model, wider than arrays and untouched by the section below.

A STRUCT carrying array fields copies through its generated ΔClone()

The aliasing above is not confined to array-typed variables: a struct whose FIELD is a fixed-size array has exactly the same problem one level up, because the plain C# struct copy carries the field’s array<T> header — and therefore its shared T[] — into the copy. crypto/sha256’s Sum is the canonical case; it copies the digest precisely so it can finalize the copy while the caller keeps writing the original:

type digest struct{ h [8]uint32; x [chunk]byte; nx int; len uint64; is224 bool }

func (d *digest) Sum(in []byte) []byte {
	d0 := *d                 // Go copies h and x INLINE
	hash := d0.checkSum()    // …then destroys d0's state finalizing it
	return append(in, hash[:]...)
}

With the copy sharing h and x, checkSum destroyed the CALLER’s running state: every second Sum on one hash returned a different digest, and TestGolden’s write-half → Sum → write-rest sequence produced the hash of the empty string. sha1/sha256/sha512 all failed the same way, as did cryptotest.TestHash’s SumAppend/ResetState/OutOfBoundsRead/StatefulWrite subtests.

The converter now treats such a struct exactly like an array. typeNeedsValueClone (arrayCloneOperations.go) is the widened gate — a fixed-size array, or a struct carrying one in a field, directly or through another such struct — so every site the array machinery already covered (assignment/var-decl RHS, composite-literal element and keyed field, map key and value, return, channel send, append element, range key/value, function/func-literal parameter and value receiver) clones a struct too. The struct declaration is stamped with the fields that need it, and go2cs-gen turns the stamp into the deep copy:

[GoType] partial struct digest {
    internal array<uint32> h = new(8);
    internal array<byte> x = new(chunk);
    internal nint nx;
    internal uint64 len;
    internal bool is224;
}

[GoRecv] internal static slice<byte> Sum(this ref digest d, slice<byte> @in) {
    ref var d0 = ref heap<digest>(out var d0);
    d0 = d.ΔClone();                 // was `d0 = d;` — the arrays were shared
    var hash = d0.checkSum();
    
}
// generated (go2cs-gen StructTypeTemplate)
internal partial struct digest : IGoValueClone
{
    public digest ΔClone()
    {
        digest copy = this;
        copy.h = h.ΔClone();
        copy.x = x.ΔClone();
        return copy;
    }

    object ICloneable.Clone() => ΔClone();
}

Four details make this correct and collision-free:

A BLANK or unnamed parameter is skipped: it is emitted under a synthetic name and can never be referenced, so there is nothing for the copy to protect — and the preamble would otherwise be written against the empty analyzed name ( = .ΔClone();, CS1525 ×2 in log/slog’s benchmark Handle(disabledHandler, context.Context, slog.Record), every parameter of which is blank). The array-typed arm had the same latent hole; no blank array parameter happened to exist in the corpus.

(Guarded by the StructArrayFieldValueCopy behavioral test — one output-compared line per site class: pointer-deref copy, ident copy, selector copy, composite-literal element, nested-struct copy, by-value parameter, returned field, array and slice index, range value, map value, value receiver. Validated end-to-end by crypto/sha1, crypto/sha256, crypto/sha512 and bufio, whose only residue is the alloc-profile disclosure the extra managed allocations force.)

Nil-vs-empty slice identity (s == nil is representation nilness, not emptiness)

Go distinguishes a nil slice (nil backing pointer) from a non-nil empty slice (a real backing pointer with zero length), and programs observe the difference through s == nil, reflect.DeepEqual, and marshaling — bytes’ TestTrim/TestTrimFunc/TestClone assert it directly (TrimRight of a non-empty slice trims in place and stays non-nil; Clone of a non-nil input must return non-nil; the []byte{} want-side literals must not classify as nil). golib slice<T> carries the distinction in its representation — the backing m_array field is null exactly for the nil slice — but the observation and two construction paths used to lose it:

The full identity enumeration golib maintains (invariant: nil ⟺ m_array is null):

Construction Go identity golib path
var s []T, struct/element zero values nil default(slice<T>) — null backing
[]T(nil), nil literal in slice context nil T[]-taking ctors map null → default
nil[0:0], nil[0:0:0] nil Reslice/bounded ctors preserve the null backing
append(nilSlice) — nothing to add nil Append returns the source header unchanged
[]T{} composite literal non-nil empty new T[]{}.slice() — real empty array
[]byte("") / []rune("") / conversions of empty strings non-nil empty span/@string paths materialize a real array
make([]T, 0) non-nil empty parameterless ctor / Make — real empty array
s[a:a], s[len(s):] of non-nil s (even cap 0) non-nil empty Reslice shares the real backing array
append(emptySlice) — nothing to add that same non-nil empty Append identity return
append(s, elems...) with elements non-nil in-place or reallocated — always a real array

Known adjacent gap, deliberately out of this change’s scope: a zero-argument variadic call materializes a non-nil empty (params Span<T>.slice()) where Go passes nil. (Guarded by the SliceNilVsEmpty behavioral test — every row of the table probed with s == nil, len, and cap against go run; resliceTailCapZero discriminates the operator fix, nilReslice the Reslice fix, and appendNilNothing the Append fix. NilSliceConversion continues to guard the []T(nil) conversion row.)

Named slice/map/channel wrappers (defined types). The distinction extends to DEFINED types (type S []int, generated as go2cs-gen InheritedTypeTemplate wrappers). The comparison Go permits on a named slice/map/channel is x == nil; the converter renders it as s == default! (nil literal in value context) or s == nil (pointer context), and — verified against the emitted C# — both bind the wrapper’s operator ==(S, NilType) overload, not the same-type operator ==(S, S) (reverting the latter has no observable effect; reverting the former flips the result). That overload emitted value.Equals(default(S)), and the slice wrapper’s Equals (from ISliceTypeTemplate’s Equals(ISlice<T>?)) is structural content equality, so an empty non-nil named slice (S{}, make(S, 0), an s[len(s):] tail) was misclassified as nil. It now delegates to slice<T>’s own == NilType — REPRESENTATION nilness (null backing array, R13) — via value.m_value == nil, so IntSlice{} == nil is false while the zero value stays nil. Audit of the other nil-comparable kinds: map and channel wrappers were already correct and are unchanged — they declare no structural Equals, so Equals(default) falls back to reference identity through the backing field (map<K,V>.Equals is ReferenceEquals(m_map, …); channel<T> compares its queue by reference). Array/numeric/string/struct/any wrappers are not nil-comparable in Go (and the pointer wrapper already uses a reference-identity Equals override), so they keep the structural default; the same-type operator ==(S, S) is likewise left untouched (Go forbids comparing two slices, so no converted code reaches it). Because this is a compile-time (go2cs-gen) change it leaves TRANSPILER output — the .cs.target goldens — byte-identical, so it is gated on the FULL behavioral suite (four phases) plus a full corpus build rather than CNR alone.

Separately, NilType’s operator ==(ISlice?, NilType) dropped its historical { Length: 0, Capacity: 0, Source: null } arm: slice<T>.Source (and every wrapper’s IArray.Source) materializes a DETACHED copy (ToSpan().ToArray()) and is never null, so the property pattern could never match — the expression already reduced to slice is null (representation nilness), which is what it now states plainly. No converted s == nil routes through this interface operator: a concrete slice<T> binds its own == NilType, and interface/any comparands bind NilType’s object arm.

(The named-wrapper rows are guarded by the NamedSliceNilVsEmpty behavioral test — named slice/map/channel zero value (nil) vs empty literal (non-nil), plus the slice resliceTailCapZero and nilReslice discriminators, output-compared vs go run; it fails if the wrapper’s == nil regresses to structural equality.)

A composite literal omitting a fixed-array field keeps the zeroed backing

A Go struct’s fixed-array field is emitted with a field initializer carrying its Go length (badCharSkip [256]intinternal array<nint> badCharSkip = new(256);), and C# runs field initializers in every explicitly declared constructor — but the generated parameterized constructor then assigned every member from its argument, and an argument the composite literal OMITS arrives as the zero value default!, whose backing T[] is null. The assignment nulled the initializer’s backing, so the field’s first walk NREd (strings’ Boyer-Moore stringFinder{pattern: …, goodSuffixSkip: …} never sets badCharSkip — the TestFinderCreation/TestFinderNext operational blocker, Phase-4 row R8). The generated constructor now guards exactly the fixed-array members:

if (badCharSkip.Source is not null) this.badCharSkip = badCharSkip;

array<T>.Source intentionally returns the RAW backing reference (null discriminates a never-constructed zero value), and keeping the initializer for a zero-value argument is precisely Go’s semantics — the zero [N]T IS the zeroed backing the initializer produced. A constructed argument assigns as before (see the copy-semantics gaps above for the literal-argument clone). Separately, golib array<T> reads are now null-safe: a bare default(array<T>) (a zero value no constructor ever touched) enumerates/compares/prints as an EMPTY array and panics Go-style on any index, instead of throwing NRE — mirroring @string’s null-safe zero value. The empty view is a disclosed approximation: the declared length only exists where a constructor or initializer ran, so a holder z = default!; zero-var local still reads its array field at length 0, not N (a known converter gap, chipped separately; the make([]S, n) half of it is now closed — see make([]E, n) constructs its ELEMENTS by the same rule below). (Guarded by the ZeroValueArrayField behavioral test — the literal-omission shape ranged/indexed/printed vs Go, plus an explicit-argument control.)

A fixed-array composite literal carries its DECLARED length (.array(N))

A [N]T{…} literal is N long however many elements it writes — Go zero-fills the rest, so [8]byte{} is eight zero bytes and [8]byte{1, 2} is 1, 2 followed by six zeros. The literal renders as a C# element array projected through golib’s .array() extension, and that element array holds only the elements actually written, so the projection produced an array as long as the LITERAL rather than as long as the TYPE. [8]byte{} became length 0: it compiled cleanly and then panicked on first use (index out of range [7] with length 0 — math/rand/v2 chacha8’s Seed, whose [8]byte{} never held a byte). The projection now takes the declared length:

a := [8]byte{}          // eight zero bytes
b := [8]byte{1, 2}      // 1, 2, then six zeros
c := [3]byte{1, 2, 3}   // already full
var a = new byte[]{}.array(8);
var b = new byte[]{1, 2}.array(8);
var c = new byte[]{1, 2, 3}.array();      // full literal keeps the plain projection

Only a short literal takes the length argument. A full literal — and every [...]T{…} ellipsis literal, whose length is its element count — already yields the right length and keeps the plain .array() form, so existing goldens for those are unchanged. A slice literal is genuinely as long as its elements ([]byte{} IS empty) and never pads; its .slice() projection is untouched. golib’s array<T>(T[] source, int length) constructor does the zero-filled copy, which is deliberately distinct from the array(slice<T>, nint) slice-to-array conversion ctor (there a short source is a Go panic; here it is the normal case).

The same dropped length reached the indexed/keyed form by a second route. A keyed literal whose indices all fold to constants renders as new array<T>(N){[i] = v}, which was already correct — but the scan used 0 as its “no constant keys” sentinel, so a literal whose only key is 0 ([8]byte{0: 9}) read as unresolved and fell to the SparseArray projection, whose extent is max index + 1, not N. Constant-key detection is now tracked separately from the maximum index, and the SparseArray projection — still used for a key that is constant but not a literal (a const identifier), which SparseArrayIfaceElem’s [kLast]shape registry exercises — also carries the declared length. (Guarded by the ArrayLiteralDeclaredLength behavioral test: empty, partial, full, ellipsis, keyed, zero-keyed, named, aliased, package-level, non-byte element types, a tail write proving the backing is really N long, and a []byte{} slice control, output-compared vs go run; the pre-fix converter exits with the index-out-of-range panic. Note a NESTED fixed array’s inner elements are still default-constructed — [2][4]byte{} gets the right outer length but inner length 0, and so does every element the padding itself creates, so [2][4]byte{{1, 2, 3, 4}} reads inner 4 then 0. The var DECLARATION path is fixed by the element factory described in the next section, but convCompositeLit does not yet use it, so the LITERAL path stays open — chipped separately.)

A fixed-size array constructs its ELEMENTS when default(T) is not usable storage

new array<T>(N) fills its backing with default(T), which is the correct Go zero value only when default(T) is itself well formed. For a NESTED fixed array it is not: [2][4]byte emits array<array<byte>>, and the inner length 4 lives only in the Go type — array<T> has nowhere to carry it, so golib cannot recover it from T. Every element kept a null backing, so len(x[1]) reported 0 where Go says 4, and the first indexed write panicked (index out of range [2] with length 0) — a silent-correctness defect that compiled clean. The same held for an element whose own zero value needs construction: default(T) skips the generated constructor that runs a struct’s fixed-array field initializers and allocates its embed boxes.

Only the converter knows the element’s shape, so it supplies an element factory to a golib array(int length, Func<T> elementFactory) constructor:

var x [2][4]byte           // len(x), len(x[1]) => 2 4
var deep [2][3][4]byte
var se [2]inner            // type inner struct { b [3]byte }
array<array<byte>> x = new(2, () => new(4));
array<array<array<byte>>> deep = new(2, () => new(3, () => new(4)));
array<inner> se = new(2, () => new());

The factory nests to any depth, and each element gets its OWN storage rather than one shared inner array. It is emitted from every fixed-array zero-value site — local var, package-level var (including the addressed-global ж<> box form), the type-ALIAS-to-array spelling, a struct’s field initializer (internal array<array<nint>> entries = new(2, () => new(3));), the heap-boxed (address-taken) local, and the new([N]T) builtin:

var leafCounts [maxBitsLimit][maxBitsLimit]int32   // addressed: copy(leafCounts[i][:i], …)
f.bits = new([maxNumLit + maxNumDist]int)
ref var leafCounts = ref heap(new array<array<int32>>(16, () => new(16)), out var leafCounts);
f.bits = (new array<nint>(316));

Those last two were the same silent-correctness defect one layer down. The heap-boxed declaration is a THIRD emission path (convertToHeapTypeDecl, a string path that never consulted arrayZeroValueArgs), and it is exactly the shape compress/flate’s Huffman coder uses — bitCountsleafCounts [16][16]int32 is boxed because copy(leafCounts[i][:i], …) slices an element, and its first leafCounts[level][level] = 2 panicked with index out of range [1] with length 0, taking compress/gzip and compress/zlib down with it. new([N]T) is a FOURTH: golib’s @new<T>() builds the zero value through the parameterless constructor, where array<T>() has no length at all, so f.bits came back length 0 (Go: 316). A NAMED array type keeps the zero-value @new<row>() form for the same reason a named element needs no factory.

A NAMED array element needs no factory and is deliberately left alone: type row [4]byte generates a wrapper that allocates its backing lazily from its own known size (go2cs-gen’s m_value ??= new row(4)), so array<row> nr = new(2); is already correct. Elements whose default(T) is a valid zero value (scalars, pointers, slices, maps) likewise keep the bare new(N), which keeps the A/B footprint to genuinely nested shapes.

This mirrors go2cs-gen’s AppendZeroValueInitializers/NeedsConstruction, which does the same for struct FIELDS, and narrows the zero-value gap disclosed above — a default! zero-var local still reads an array field at length 0. (Guarded by the NestedFixedArrays behavioral test: inner len, writes read back through inner arrays, per-element storage independence, three-level nesting, struct/named-array elements, and the global paths, all compared against go run.)

make([]E, n) constructs its ELEMENTS by the same rule

slice<T>’s length constructor fills its backing with default(T) exactly as array<T>’s does, so the identical silent-correctness defect reached make. make([][hashSize]int, n) emitted new slice<array<nint>>(n) and produced n zero-length arrays, because the inner length lives only in the Go type — so hash/maphash’s avalancheTest1 panicked on its first g[j] += … (index out of range [0] with length 0), and image/draw’s Floyd-Steinberg quantErrorCurr/Next rows and x/text/transform’s chain buffers carried the same latent defect unexercised.

make now threads the same arrayElemFactory the fixed-array zero-value sites use into a golib slice(nint length, Func<T> elementFactory, nint capacity = -1, nint low = 0) constructor — one rule, one predicate, both containers:

grid := make([][hashSize]int, n)          // hash/maphash smhasher_test.go
q := make([][4]int32, r.Dx()+2, cap)      // image/draw
var grid = new slice<array<nint>>(n, () => new(64));
var q = new slice<array<int32>>(r.Dx() + 2, () => new(4), cap);

The factory fills the whole backing, not just the first length elements: Go zeroes the entire allocation, so the capacity beyond the length is already valid storage once a re-slice or append exposes it. As with array<T>, a NAMED array element (type row [4]byte) and every element whose default(T) is a valid zero value keep the plain length constructor, so the A/B footprint stays on genuinely nested shapes. The composite-literal path ([][4]int{{…}}) is still open, chipped separately with the array-literal case above. (Guarded by NestedFixedArrays, extended with the make length and length+capacity forms, a struct element needing construction, and a named-array element control, output-compared vs go run.)

A DEFINED slice type routes the factory through its underlying slice<E> (2026-07-27). make’s target is not always slice<E>: for type SortedMap []KeyValue the target is the go2cs-gen wrapper, which declares SortedMap(nint length, nint capacity = -1, nint low = 0) and no element-factory overload, so the lambda bound to nintCS1660: Cannot convert lambda expression to type 'nint' at internal/fmtsort’s make(SortedMap, 0, n). The factory-filled backing is therefore built as the underlying slice<E> — bit-for-bit the value the unnamed form produces — and handed to the wrapper’s T(slice<E> value) constructor, which the generator always emits:

sorted := make(SortedMap, 0, n)           // internal/fmtsort sort.go
var sorted = new SortedMap(new slice<KeyValue>(0, () => new(), n));

This was a live defect, not a latent one, and its blast radius is worth recording: -tests regenerates production .cs on every run, so once the run for one banked package left a broken sort.cs on disk, every later package downstream of fmt failed to build too — a validated sweep read as 41 pass / 20 fail from this single root. It is the fourth instance of the zero-value- construction class in a fourth emission path, which is the standing argument for centralizing that construction rather than patching sites.

clear rebuilds each element through golib’s GoZero — the RUN-TIME half of zero-value construction

The fifth instance of that class landed in golib rather than the converter, and it is the one that made the centralization real. builtin.clear(slice<T>) assigned default! to every element, so clear(q) over a [][4]int32 replaced each element with a length-zero array — Go’s clear leaves four zeroed int32s. image/draw’s Floyd–Steinberg dither calls clear(quantErrorNext) once per scan line, and the next row’s quantErrorNext[x][0] panicked with index out of range [0] with length 0. The same held one level in for a struct element carrying a fixed-array field: default! skips the generated parameterless constructor that runs its field initializers.

The converter cannot help here — a clear call site has no Go type shape to thread a factory through, and the C# type array<E> is the same for every N. Patching the site would have been the fifth per-site repair of one defect, so the shape recovery moved into golib and became a single entry point:

public static T GoZero<T>(T template)   // builtin.cs

GoZero returns the Go zero value of T, consulting template only for run-time shape, and resolves one of three answers from a per-closed-T static (ZeroFacts<T>, the same JIT-folding shape as AssertFacts<T>), so the overwhelmingly common case compiles to a constant default:

T zero
any reference type, or a value type golib owns (@string, slice<T>, map<K,V>, the numerics) default
array<E> — implements the new golib marker IGoZeroShaped GoZeroLike(): a new array of the template’s LENGTH, elements zeroed recursively so [2][3]int32 keeps its inner lengths
a converted Go struct ([GoType] + a generated parameterless constructor) that constructor — exactly what the converter emits for var x T, and default for a plain struct

All three slice-shaped clear overloads (slice<T>, Span<T>, and the constrained ISlice<T>) route through one Span<T> body that keeps the vectorized Span.Clear() whenever default(T) is already the Go zero value:

q := make([][4]int32, 3)
q[1][2] = 7
clear(q)
len(q[1]) // 4, not 0

The [GoType]-plus-constructor rule is deliberately broad rather than a per-shape enumeration (fixed-array field, promoted embed, …): calling a converted struct’s own zero-value constructor is always correct, so a FUTURE field shape that needs construction is covered without re-opening the class a sixth time. That generality is the whole point — this is the run-time counterpart of the converter’s arrayZeroValueArgs and go2cs-gen’s AppendZeroValueInitializers, which build a zero value where the shape is known statically; GoZero recovers it from a value that already carries it, which is what a built-in is handed. (Guarded by the ClearBuiltinShadow behavioral test, extended with clear over an array-element slice, a struct-with-array-field slice, and an array-of-arrays element, each written to after the clear and output-compared vs go run.)

A named slice wrapper’s non-generic ISlice.Append is an EXPLICIT implementation

The generated wrapper for type S []E implements both halves of the golib slice surface, and both declare an Append: the typed ISlice<E>.Append(E[]) and the non-generic ISlice.Append(object[]). ISliceTypeTemplate emitted both public, which is fine for every E except one — with E = any, object[] and E[] are the SAME parameter list, so the wrapper carried two public methods differing only in return type: CS0111. That is a single duplicate-member emission, and it held two whole converted test suites, fmt’s type SE []any (63 verdicts) and archive/tar’s type fileOps []any (97) — a []any named slice is a table-driven-test idiom, which is why the production corpus never met it.

The non-generic overload is now explicit —

ISlice? ISlice.Append(object[] elems) => ((ISlice)m_value).Append(elems);

— which is what golib’s own slice<T> has always declared (ISlice ISlice.Append(object[] elems) beside ISlice<T> ISlice<T>.Append(params T[] elems)), so the wrapper now matches the type it wraps. The reasoning is the same one the template already applies to GetEnumerator in the subsection above: this is the boxing, interface-typed path, taken only when a consumer asks for the interface, and the public surface is the typed overload. Converted code never calls it by name — Go’s append emits golib’s append builtin, which reaches slice<T>.Append statically.

Measured after: both suites clear this root and stop on unrelated ones — fmt on five (CS1955 map used as a method, CS0030 on renamed complex types, CS1729/CS0103/CS0034 around Scan_type), archive/tar on the duplicate global using alias its board row records as closed and which is in fact still live. Neither banks. Guarded by the NamedAnySliceType behavioral test — both suites’ declarations verbatim, spread into a variadic ...any, appended to, indexed, ranged, sub-sliced, spread into a second named []any, and compared against nil, output-compared vs go run.

Strings (@string and sstring)

Go’s string is represented by golib @string, not System.String. That is a semantic decision, not just a naming one: Go strings are immutable byte sequences, so len, indexing, ranging, concatenation, conversion to []byte/[]rune, equality, and type assertions must all observe Go’s UTF-8/byte model rather than C#’s UTF-16 string model. A zero-value @string is also null-safe and reads as "", which lets default! stand in for Go’s zero value without sprinkling null checks through converted code.

Plain Go string literals usually render as C# UTF-8 literals ("..."u8, a ReadOnlySpan<byte>) and are target-typed only at the boundary that needs an actual Go string. That gives allocation-free fast paths such as []byte("hi") -> slice<byte>("hi"u8), @string s = "hi"u8, and comparisons against sstring views. When the literal’s bytes cannot be represented faithfully as source UTF-8 – notably high \xHH escapes and greedy hex-escape runs – the converter emits a byte-array-backed @string instead, so byte indexing and len stay Go-correct.

Named string types are generated as real [GoType("@string")] wrappers. The generator supplies the Go string surface directly on the wrapper – byte indexers, range/sub-slice behavior, Length for len, ReadOnlySpan<byte> bridging for u8 literals, comparisons, and conversions through the underlying @string – so code that declares type Token string keeps distinct-type behavior while still reading like a string in method bodies.

The heap @string form is always the correctness fallback for string([]byte): it copies bytes into an immutable string, matching Go when the value escapes or the source buffer can later mutate. The performance fast path is golib sstring, a stack-only readonly ref struct view over a ReadOnlySpan<byte>. A local or expression-level string([]byte) conversion may emit sstring only when the converter can prove the view is read-only, non-escaping, and not observed after a source mutation; if that proof is too weak, the conversion stays @string. Because sstring is a ref struct, most missed escape cases are C# compile errors rather than silent aliasing bugs.

A built-in used as a generic type argument is rendered in its golib form, the same as anywhere else — in particular Go string becomes golib @string, never C# string (System.String). This matters because the converter adds a new() constraint to every generic type parameter: @string is a struct with a public parameterless constructor and satisfies it, whereas System.String would violate it (CS0310), and assigning a string literal — emitted as a u8 ReadOnlySpan<byte> — into such a field would fail (CS0029). So:

type Pair[A any, B any] struct { a A; b B }
var p Pair[int, string]
p.b = "hi"
Pair<nint, @string> p = default!;
p.b = "hi"u8;

This applies uniformly to every type-argument position — first, second-or-later, and nested (Pair[int, Box[string]]Pair<nint, Box<@string>>). (The behavioral test GenericStringTypeArg guards these cases; NestedGenericTypes covers the nesting depth without string args.)

Slicing a string is a WINDOW, not a copy — @string carries an offset and a length

A Go string header is a pointer plus length into shared immutable storage, which is what makes s[i:j] an O(1) operation that allocates nothing. @string originally held a bare byte[], so its range indexer had to materialize the sub-string’s bytes: s[i:] was O(n) with an allocation. That is invisible in the small and quadratic in the ordinary Go idiom for walking a string by runes —

for i := 0; i < len(s); {
    r, size := utf8.DecodeRuneInString(s[i:])
    i += size
}

— which is exactly what archive/zip’s detectUTF8 does over every file name and comment. At the 65,535-byte names its TestZip64LargeDirectory builds, each call copied ~2.1 GB; the test takes 13.2 s in Go and had not finished in 45 minutes in C#.

@string therefore carries the Go header’s shape — a backing array plus an offset and a length — and its range indexer returns a window over the same array. Slicing a string now allocates nothing and copies nothing, matching Go’s cost model. The same test completes in 20.2 s against Go’s 11.3 s, and archive/zip validates 98/98.

Sharing the backing array is safe for precisely the reason it is safe in Go: @string is immutable, and every conversion out to storage the receiver may mutate — []byte(s), the byte[] operator — already copies (see the next section, and the unicode/utf8 TestDecodeRune corruption that pinned those copies down). Two consequences worth carrying:

Converting a string to []byte / []rune

A Go []byte(s) / []rune(s) element-decoding conversion is emitted as the golib element-slice form slice<byte>(…) / slice<rune>(…), which relies on the @stringslice<byte>/slice<rune> conversion. When the source is a string variable it is already golib @string, so the conversion applies directly. When the source is a bare string literal, that literal would otherwise render as a System.String (no such conversion exists — CS1503/CS1929), so the converter casts it to @string first:

bs := []byte("hello")
rs := []rune("héllo")
var bs = slice<byte>((@string)"hello");
var rs = slice<rune>((@string)"héllo");

The @string cast fires only on a string-literal argument; a string-variable conversion ([]byte(s)) needs no cast. (Guarded by the behavioral test StringLiteralSliceConversion.)

The cast reaches a top-level literal argument only, which left Go’s line-splitting literal idiom — one constant string written as several +-joined pieces so it fits the source width — rendering as a bare C# string concatenation. C# will not chain the two user-defined conversions string@stringbyte[] that golib’s slice<T>(T[]) would need, so crypto/hmac’s long-key vectors failed CS1503: cannot convert from 'string' to 'byte[]'. A []byte/[]rune conversion whose argument is a constant-valued binary expression therefore casts the rendered operand as a whole, which keeps the source’s split verbatim (C# constant-folds the concatenation itself, so preserving it costs nothing at run time):

key := []byte("This is a test using a larger than block-size key " +
    "and a larger than block-size data. The key needs to " +
    "be hashed before being used by the HMAC algorithm.")
var key = slice<byte>((@string)("This is a test using a larger than block-size key " + "and a larger than block-size data. The key needs to " + "be hashed before being used by the HMAC algorithm."));

The gate is deliberately narrow — a + chain whose every leaf is a string literal that renders plainly — so it fires on exactly the shape that produces a bare C# string and nothing else. A non-constant concatenation (s + "x") already yields an @string through its variable operand; an ident or selector naming a string constant emits its declared symbol; and a raw-byte (\xHH) leaf takes convBasicLit’s byte-ARRAY route, which yields an @string that carries the whole chain with it (slice<byte>("" + ((@string)(new byte[]{0xff, 0x80}))) — the ByteTableStringVar case, byte-identical across this change). (Guarded by StringLiteralSliceConversion, which now also covers the split []byte/[]rune idiom and a raw-literal piece in the chain.)

string([]rune) encodes an INVALID rune as U+FFFD, never fails

Go’s rune-to-string conversions replace every invalid rune — a surrogate (0xD8000xDFFF) or an out-of-range value (< 0 or > 0x10FFFF) — with U+FFFD (utf8.RuneError, bytes EF BF BD), one replacement per invalid element: string([]rune{0xD800}) is "�" (probed vs go run). Every golib rune-span encoding routes through one seam, builtin.ToUTF8Bytes (the @string rune-span constructor, the slice<rune>/single-rune@string operators, and rune append all land there), which used the element conversion intSystem.Text.Rune — and that conversion THROWS ArgumentOutOfRangeException for exactly Go’s invalid values, killing the host instead of producing the replacement bytes (strings’ TestCaseConsistency builds a string of every rune 0..MaxRune, surrogates included — Phase-4 row R7). The encoder now uses Rune.TryCreate(value, out codePoint) and substitutes Rune.ReplacementChar on failure; the 4-bytes-per-rune buffer estimate still covers the 3-byte replacement. (Guarded by the InvalidRuneString behavioral test — slice and single-rune conversions over runtime values, byte values and lengths compared vs Go; runtime values keep both compilers from constant-folding the conversions.)

Converting a string literal to a named string type

A Go conversion of a string literal to a named type whose underlying type is stringerrorString("…") where type errorString string — needs the same @string intermediate. The literal renders as a u8 ReadOnlySpan<byte>, which has no conversion to the named type, so a bare (errorString)"…"u8 is CS0030. The converter routes it through @string (which converts implicitly from the u8 span and to which the named type converts):

return errorString("kaboom")
return ((errorString)(@string)"kaboom"u8);

This is the form the runtime uses for every panic(errorString("…")) / plainError("…"). (Guarded by the behavioral test NamedStringConversion.)

The same intermediate is needed with NO explicit Go conversion written — a named string type’s zero value in a RESULT position (2026-08-08). Go converts an untyped string constant to a defined string type implicitly, so the source says only return nil, ""; the emitted C# still has to cross the two user-defined conversions, so a bare "" has no conversion to the named type at all (CS0029). In a multi-result return the damage spreads: the failed element leaves its tuple siblings with no target type either, so the default! beside it is CS8716. The result type’s being a defined type over string is the signal, and the literal takes the @string step:

// os/zero_copy_linux.go — poll.String is `type String string`
func getPollFDAndNetwork(i any) (*poll.FD, poll.String) {
    sc, ok := i.(syscall.Conn)
    if !ok {
        return nil, ""
    }
return (default!, (@string)"");

A plain string result is deliberately excluded (it emits as @string, which a literal already reaches in one conversion), as is a type parameter, whose emitted form is not a [GoType] wrapper. An ALIAS of a named string type is the same type and takes the same route. (Guarded by the behavioral test NamedStringZeroValue — the multi-result shape that carries the cascade, the single-result shape, the alias, and the named type still behaving as a string under +, += and ==, stdout-compared against go run.)

A POSITIONAL struct-composite element in a string field renders u8

Go requires a positional composite literal to list every field in order, so element i is field i. A string-literal element whose field is a plain string renders as the u8 span, which binds the generated constructor’s @string parameter through one implicit conversion:

type StructuralError struct{ Msg string }   // encoding/asn1
return StructuralError{"empty integer"}
return new StructuralError("empty integer"u8);

KEYED elements (fileListEntry{name: "./"}) and ELIDED positional elements ([]pair{{"e2", …}}) already emitted u8 — they route through convKeyValueExpr and the elided element context respectively — so this only closes the TYPED positional gap, where the element stayed a bare C# string that Encoding.UTF8.GetBytes transcoded on every evaluation. It was the corpus’s last converter-emitted bare-UTF-16 literal class in a constructor position: 83 sites across 9 types (asn1’s StructuralError/SyntaxError, net/http’s ProtocolError/contextKey, math/big’s ErrNaN, encoding/xml’s UnmarshalError, net/url’s Error, …). An any field keeps the boxed (@string)"…"u8 form (markAnyFieldLits runs after and overrides).

A string-literal ELEMENT of a typed slice/array composite renders u8

The element twin of the struct-field rule above. An elided slice/array literal already rendered u8 (its element context is nil, and convExprList’s nil-context default leaves u8StringOK on), but the typed form built its own CallExprContext and never set u8StringArgOK, so every element fell back to a bare UTF-16 C# string that Encoding.UTF8.GetBytes re-transcoded on each evaluation:

return StructuralError{[]string{"Format specifies USTAR", whyNoUSTAR}}   // archive/tar
return new headerError(new @string[]{"Format specifies USTAR"u8, whyNoUSTAR}.slice());

The span binds the @string element slot through @string’s implicit ReadOnlySpan<byte> conversion; a NAMED type over string binds the same way through its generated span conversion, so the rule keys on the element type’s underlying basic kind (matching markStringFieldLits). An empty-interface element type — []any{"a"} — keeps its mandatory (@string) box but now takes the u8 half too (new any[]{(@string)"a"u8}): the cast is what makes the span boxable, the u8 is what keeps the bytes a compile-time constant. These two were the corpus’s last converter-emitted bare-UTF-16 literal classes — 385 new @string[]{"…"} element sites plus the any-element form. KeyValueExpr elements (maps, sparse arrays) are not BasicLits and route through convKeyValueExpr, which already emitted u8. (Guarded by the behavioral tests AnyStringLitComposite, DeepEqual, GenericCompositeLiterals, and 15 others whose goldens carry the form.)

A string CONCAT element of a composite literal keeps the u8 span form

The two rules above mark a composite’s string-typed element slots as span-tolerant, but they marked only the elements that were BasicLits. That flag does double duty: convExprList derives spanTargetUnsupported from it, and convBinaryExpr reads that to suppress u8 on a string concat’s literal operand. A concat element is an ast.BinaryExpr, never a BasicLit, so it left its own slot looking span-hostile and the literal fell back to bare UTF-16:

allowed := []string{prefix + "-a", prefix + "-b"}
var allowed = new @string[]{prefix + "-a", prefix + "-b"}.slice();     // before
var allowed = new @string[]{prefix + "-a"u8, prefix + "-b"u8}.slice(); // after

The suppression itself is correct where it was born — an object[] vararg slot cannot box a ReadOnlySpan<byte>, so fmt.Println(allowed[0] + ":" + msg) must keep plain operands (CS1503) — but a string element slot is not span-hostile, which the sibling spellings already proved: the same concat rendered u8 when parenthesized (at the time, because convParenExpr dropped the incoming literal context — a defect in its own right, fixed in the next section) and when the composite’s type was elided (nil element context). Marking every positional element makes the three agree, and it is what keeps the parenthesized spelling on the span form now that parentheses inherit their slot’s context instead of discarding it. The span operand then binds golib’s operator +(@string, ReadOnlySpan<byte>), which block-copies the literal’s ROM bytes straight into the single result buffer — no Encoding.UTF8.GetBytes transcode, no throwaway intermediate @string. Two literal operands need no operator at all: C# folds "x"u8 + "y"u8 into one UTF-8 literal at compile time.

A SLICED string literal in a concat needs one real @string operand

That last sentence is the whole hazard: C#’s + over UTF-8 spans is a literal-only compile-time feature, and a slice of a literal is not a literal. Go’s format_test.go builds a fraction the obvious way:

nanosec, err := strconv.ParseUint("012345678"[:test.fracDigits]+"000000000"[:9-test.fracDigits], 10, 0)

Both operands render as "…"u8[..n], i.e. two bare ReadOnlySpan<byte> values with no operator between them (CS9047). The literals deliberately keep their u8 form — Go slices a string by bytes, so re-rendering them as C# string and slicing that would index by UTF-16 code units, right for ASCII and silently wrong for anything else. Instead the sliced operand is cast to @string, which gives the concat one real operand and lets golib’s operator +(@string, ReadOnlySpan<byte>) (or its mirror) bind while the other half stays a span:

strconv.ParseUint(((@string)"012345678"u8[..(int)(test.fracDigits)]) + "000000000"u8[..(int)(9 - test.fracDigits)], 10, 0)

Only token.ADD is affected; a comparison against a sliced literal already binds golib’s span-aware operators and keeps its zero-allocation form. (Guarded by the PackageNameShadowing behavioral test, case 5.)

For a slot of a named string type the old form did not merely cost a transcode — it did not compile. The generated [GoType("@string")] wrapper carried the span comparison operators but no + at all, so C# fell back to converting both operands to a C# string and calling string.Concat: the result was a string where the wrapper was wanted (CS0029 in an element slot, CS1503 in a generated constructor), and a u8 operand had no candidate to reach at all (CS0019). InheritedTypeTemplate now mirrors @string’s concat set — +(T, T), +(T, ReadOnlySpan<byte>), +(ReadOnlySpan<byte>, T) — so a named string type survives a concat exactly as Go says it does, keeping its method set:

type version string
func (v version) tag() string { return string(v) + "!" }

short := base + "-rc"   // still a version, so short.tag() stays callable
version @short = @base + "-rc"u8;

The same BasicLit-only gate applied to markStringFieldLits, so a positional STRUCT element in a string field had the identical defect (rec{base + "-s"} over a version field was CS1503); both gates now mark every positional element. KEYED elements still skip — index i is not field i for them, and convKeyValueExpr resolves their slot itself. (Guarded by the behavioral tests CompositeElementStringConcat, which also pins the vararg suppression that must NOT change, and NamedStringConcat; ReturnTupleFuncLitArg’s golden carries the slice-element form.)

Parentheses inherit their slot’s literal context

Parentheses are transparent in Go: (x) lands in the enclosing slot exactly as x does. But convParenExpr rendered its operand with a fresh context list — it built the StarExprContext the pointer-cast path needs and passed only that — so the incoming BasicLitContext never reached the operand. The slot’s span-tolerance signal (spanTargetUnsupported, above) was silently reset to the default tolerant, and a pair of parentheses could re-enable the u8 span form inside a span-hostile slot:

panic("a" + "b")     // suppressed correctly
panic(("a" + "b"))   // parenthesized — the same slot, the opposite rendering
throw panic("a" + "b");         // before and after
throw panic(("a"u8 + "b"u8));   // before — CS1503
throw panic(("a" + "b"));       // after

C# folds two adjacent utf8 literal constants into a single ReadOnlySpan<byte>, and a span has no boxing conversion to object, so the parenthesized spelling did not merely differ — it did not compile. panic is where this surfaces because it is the one span-hostile slot with no second line of defence: the others pick up an outer (@string) box cast whose helper (constExprIsStringLiteralConcat) already unwraps ParenExpr, while panic short-circuits in convCallExpr before convExprList ever runs. With a non-constant operand the drop was invisible rather than fatal — (a + "-y"u8) binds golib’s operator +(@string, ReadOnlySpan<byte>) and yields an @string, which boxes fine — but it still rendered the parenthesized and unparenthesized spellings of one expression differently.

The fix threads the incoming literal context through the paren arm instead of replacing it; the StarExprContext is passed alongside it, so the pointer-cast path is unchanged. Every span-hostile slot — panic, a vararg any, an interface-typed assignment, return, or ValueTuple element — now renders both spellings alike.

The inverse must hold too: a composite literal’s string element slot is span-tolerant, and its parenthesized concat has to keep the span form. It does, through that composite’s own per-element gate (previous section) rather than through the dropped context — which is exactly why the two changes belong together:

elems := []string{a + "-g", (a + "-h")}
var elems = new @string[]{a + "-g"u8, (a + "-h"u8)}.slice();

Re-transpiling the behavioral corpus and reconverting the full standard library both produce byte-identical output, so this is latent-defect hardening rather than a rendering change: no site in either corpus spells a concat this way today. (Guarded by the behavioral test ParenthesizedConcatContext, which spells every affected slot both ways so the pair must agree.)

One related gap is deliberately left open. The decisions that mark a string literal boxable — u8StringArgOK / useGoStringArg in convCallExpr, and the same test in visitSendStmt, convKeyValueExpr, convCompositeLit, markAnyFieldLits, and convFuncLit — all gate on isStringBasicLit, a bare *ast.BasicLit type assertion that does not unwrap parentheses (unlike constExprIsStringLiteralConcat, which does). So a parenthesized standalone literal in an any slot (fmt.Println(("lit"))) is not recognized as one, and now renders (@string)(("lit")) rather than the constant-span (@string)(("lit"u8)) it got by accident from the dropped context — correct, and one Encoding.UTF8.GetBytes per evaluation slower. Making those gates paren-aware is the general fix for that family; it is a distinct change with its own footprint and is not folded in here.

Converting a string literal to a named []byte / []rune type

The byte/rune-slice sibling of the named-string rule above: a string literal converting to a named type whose underlying is []byte or []runehtmlSig("<!DOCTYPE HTML") where type htmlSig []byte (net/http sniff.go’s signature table) — cannot cast directly either. The u8 span converts to neither the [GoType] wrapper (whose implicit operator takes exactly its underlying slice<byte>/slice<rune>) nor through @string in one hop (C# chains at most one user-defined conversion — CS0030). The converter materializes the underlying slice exactly the way the plain []byte("…") conversion does (the slice<T>(T[]) builtin over the literal’s @string), and the wrapper’s own operator then applies:

type htmlSig []byte
sig := htmlSig("<!DOCTYPE HTML")
var sig = ((htmlSig)slice<byte>((@string)"<!DOCTYPE HTML"u8));

The rune form decodes code points — runeSig("héllo") yields a rune-counted slice<rune> — matching Go’s conversion semantics. String variables are unaffected (no instance in the corpus; a named-slice wrapper conversion from a @string variable would surface as a loud CS0030, not silent misbehavior). (Guarded by the behavioral test NamedByteSliceFromStringLit — direct, composite-element, and argument positions, byte/rune element reads, all output-compared vs Go.)

A string literal with high raw-byte escapes (\xHH hex, \NNN octal) emits a byte-array @string

Go’s \x escape is exactly two hex digits denoting one raw byte; C#’s \x escape is a greedy 1-to-4-hex-digit code-unit escape, and a C# "…"u8 literal UTF-8-re-encodes its content. So re-emitting a Go token verbatim as a C# string literal both (a) mis-parses \xdb followed by ASCII "5""0" (the token \xdb50) as the single code unit U+DB50 — a lone high surrogate that cannot UTF-8-encode into a golib @string (CS9026, time/tzdata’s embedded zip blob) — and (b) silently widens every byte ≥ 0x80 to two UTF-8 bytes, so @string byte indexing / len would not match Go. Such literals are emitted as the exact bytes in a parenthesized byte-array-backed @string:

const zipdata = "\x50\x4b\x03\x04\xdb50\xff\x92\x00LMT"   // raw bytes
internal static readonly @string zipdata = ((@string)(new byte[]{0x50, 0x4b, 0x03, 0x04, 0xdb, 0x35, 0x30, 0xff, 0x92, 0x00, 0x4c, 0x4d, 0x54}));

The outer parentheses are load-bearing: an inline-indexed literal ("…"[i]) would otherwise bind [i] to the inner byte[]. Only a raw-byte escape trips it — for \xHH, a byte value ≥ 0x80 or a trailing hex digit (the octal companion is below) — so a literal written with actual UTF-8 characters ("Michał", "白鵬翔") round-trips through "…"u8 and keeps the readable string form, as does an all-ASCII escape run with no greedy extension (image/jpeg’s "\x00\x10\x01\x11"u8[i]), and there is no behavioral-golden churn. (Guarded by the HexByteStringLiteral behavioral test.)

Go spells a raw byte two ways, and the same rule covers both: \NNN is exactly three octal digits, likewise denoting one byte. Octal has no greedy hazard — Go’s escape is exactly three digits and the C# \uXXXX it would render as is exactly four hex digits, so neither side can extend into the following text — but the byte-width hazard is identical and just as silent: Go’s "\377" is the single byte 0xFF, whereas the character U+00FF that replaceOctalChars would emit UTF-8-encodes to the two bytes 0xC3 0xBF. The UTF-16-string and u8 renderings produce those same wrong bytes, so an octal escape ≥ \200 takes the byte-array path as well; below \200 it is ASCII-safe and keeps the readable form:

const octalData  = "\377\200\303\277\101\000\177Z"   // 8 raw bytes
const asciiOctal = "\101\102\011\103"                // ASCII "AB\tC"
internal static readonly @string octalData = ((@string)(new byte[]{0xff, 0x80, 0xc3, 0xbf, 0x41, 0x00, 0x7f, 0x5a}));
internal static readonly @string asciiOctal = "\u0041\u0042\u0009\u0043"u8;

Left unfixed, len(octalData) is 12 rather than 8 and every byte index past the first is wrong. This one is worth recording as a latent defect: it was found by design review, not by a miscompile, and the corpus had no instance of it (CNR is byte-identical across the behavioral corpus, and the rule is purely additive — it can only divert literals that were already being emitted with the wrong bytes). Note that the folded-value rule below catches the octal case for concatenated constants by a different test (utf8.ValidString), since a lone \377 byte is not valid UTF-8. (Guarded by the extended HexByteStringLiteral behavioral test — a high-octal table, the \200 low boundary, sub-0x80 controls asserting the readable form survives, and a non-const local, all byte-indexed and len-measured, output-compared vs go run; stringLiteralNeedsByteArray’s rule — both escape forms, the sub-0x80 controls, and the escaped-backslash parity cases — is unit-tested in convBasicLit_test.go.)

The sub-\200 rewrite that renders the readable form obeys the same backslash-parity rule, and it did not. replaceOctalChars matched \NNN with a plain regex, so in "\\101" — an escaped backslash followed by the ordinary characters 1, 0, 1 — it matched from the SECOND backslash and emitted "\\u0041", whose C# value is the six characters \u0041 where Go’s is the four characters \101. Wrong content, wrong length, silently. ("\\377" is the same case above the diversion boundary: parity keeps it out of the byte-array path too, since it holds no raw byte.) The rewrite is now a positional parity scan — a \NNN is an escape only after an ODD run of backslashes — which also fixes a second defect of the regex form: it paired FindAllString with strings.Replace(…, 1), replacing the first textual occurrence of each match rather than the matched position, so a literal carrying both forms rewrote the escaped one twice:

const escapedOctal = "\\101|\101|\\\101|\\377"   // Go: `\101` | 'A' | `\`+'A' | `\377`
internal static readonly @string escapedOctal = "\\101|\u0041|\\\u0041|\\377"u8;

Three octal digits cap at \777 = 0x1FF, so the C# \uXXXX code-unit escape always suffices (the old \UXXXXXXXX branch was unreachable). The same helper feeds the token.CHAR path, where the parity case cannot arise (a rune literal holds one character) but the escape rewrite is shared. This was found by review, not by a miscompile: CNR shows no corpus instance (byte-identical apart from the guard’s own golden). (Guarded by the extended HexByteStringLiteral behavioral test — both forms as a const and as a local, plus the rune pair, output-compared vs go run — and by TestReplaceOctalChars.)

The above routes a single *ast.BasicLit through convBasicLit’s scan. A string constant whose value is a concatenationconst rev8tab = "" + "\x00\x80…" + … (math/bits’ bit-reversal table) — folds to one value with no single BasicLit, so it bypassed that scan and rendered a UTF-16 string literal: rev8tab[1] returned 0xC2 (the UTF-8 lead byte of U+0080), not 0x80, and Reverse8 was wrong. The const-string path now tests the FOLDED value directly — a value that is not valid UTF-8 (utf8.ValidString) cannot round-trip through a C# string/u8 literal, so it emits the same byte-array @string from its exact bytes (byteArrayStringLiteral, shared with emitByteArrayString); a valid-UTF-8 value keeps the readable getStringLiteral form. This catches any non-UTF-8 byte table built by concatenation (crypto S-boxes, embedded blobs), not just single literals. (Guarded by the ByteTableStringConst behavioral test — a concatenated \x00\x80… table byte-indexed and len-measured, output-compared vs go run; the pre-fix converter returns 0xC2 for index 1. The full corpus compiles with the byte-array consts, and CNR is byte-identical.)

Valid UTF-8 is not sufficient, and that gap cost net/http/fcgi a row (2026-08-09). The folded arm’s utf8.ValidString test answers only the byte-widening half of the round-trip; the greedy- escape half above applies to a folded constant exactly as it does to a BasicLit. FastCGI’s const want = "\x01\n\x00\x00\x00\x12\x06\x00" + "\x0f\x01FCGI_MPXS_CONNS1" + … folds to a value that is entirely ASCII — perfectly valid UTF-8 — so it took the readable path and emitted \x0f\x01FCGI…, in which C# reads \x01F as U+001F and eats the F. Nothing failed to compile; TestGetValues simply compared a correct response against its own corrupted constant, and reported a %q diff whose cause is invisible unless you already know C#’s escape is variable-length. The arm now applies stringLiteralNeedsByteArray to the folded value’s own quoted form, so BOTH declaration routes ask exactly the same question and diverge only where the answer genuinely differs. Corpus reach, measured before the fix: one live site — every other \x+hex-digit run in the emitted corpus sits inside a C# verbatim (@"…") literal, where \x is two ordinary characters. (Guarded by the extended ByteTableStringConst behavioral test, whose second constant is the ASCII-only "\x0f\x01" + "FCGI_MPXS_CONNS1" + "\x0a\x0d" + "BEEF" — two greedy sites, byte-indexed, len- and %q-printed, output-compared vs go run; and by net/http/fcgi’s banked suite.)

The var form of the same table needs no separate rule, and it is worth stating why, because the two declaration kinds reach the byte-array emission by genuinely different routes. A const is folded by go/types, so the concatenation is gone by the time the declaration is emitted and only the folded value can be inspected — hence the utf8.ValidString test above. A var’s initializer is rendered as an expression: var tbl = "" + "\xff…" + … walks the BinaryExpr and converts each operand through convBasicLit, so every piece is scanned on its own and the non-UTF-8 pieces become byte-array @strings that then concatenate as @strings:

internal static @string tbl = ""u8 + ((@string)(new byte[]{0xff, 0x00, 0x80})) + ((@string)(new byte[]{0x01, 0xfe}));

This holds for a package-level var, a function-local var, an explicitly typed var (var t string = …), a single non-concatenated literal, and a []byte("" + "\xff…") conversion. Note encoding/hex’s reverseHexTable — the 256-byte table that motivated a second look at this area — is a const, already covered by the folded-value rule; the corrupted UTF-16 literal still visible in a stale src/core/encoding/hex/hex.cs is pre-fix output, not current converter behavior. (Guarded by the ByteTableStringVar behavioral test — package-level, local, typed, and single-literal non-UTF-8 tables byte-indexed and len-measured, plus valid-UTF-8 controls asserting the readable literal form and UTF-8 byte-count len, output-compared vs go run.)

A value-materializing string literal is HOISTED to a static readonly field beside its first use

Go keeps string literals in RODATA: return "true" allocates nothing in a Go binary. Emitted inline, the converted C# pays a fresh backing byte[] at every evaluation, because each literal→@string materialization copies the u8 span. A whole-package pre-pass therefore hoists each package-unique literal that materializes a VALUE to one private static readonly field, declared immediately above the function whose body holds its first package-wide use, and every use site becomes a field reference — so the literal costs at most one allocation per program run:

// strconv/atob.go
func FormatBool(b bool) string {
	if b {
		return "true"
	}
	return "false"
}
// Hoisted @string literals (single allocation; Go keeps these in RODATA)
private static readonly @string trueˢ = "true"u8;
private static readonly @string falseˢ = "false"u8;

// FormatBool returns "true" or "false" according to the value of b.
public static @string FormatBool(bool b) {
    if (b) {
        return trueˢ;
    }
    return falseˢ;
}

What hoists. Only contexts that materialize a value, where a shared immutable @string is indistinguishable from a fresh one: a value return; an assignment to a local, parameter or struct field; an argument bound to a string parameter (including a variadic ...string element); an any/empty-interface target (argument, result, channel send, assignment); a standalone map-index key; and a conversion of the literal to a named string type. A literal whose EVERY package use is an any target is emitted pre-boxedprivate static readonly object xˢ = (@string)"…"u8; — so those sites allocate nothing at all. Mixed-use literals get one @string field and box per any call; there are never two fields for one literal.

What does not hoist, and why — deterministic filters, not hotness heuristics:

Context Why it stays inline
comparison operands (incl. lowered switch chains) already zero-allocation: @string and every named string type compare against a u8 span in place
concat operands (x + "…") operator +(@string, ReadOnlySpan<byte>) already consumes the span without materializing it
[]byte("…") / []rune("…") sources the result must be freshly MUTABLE; that one allocation is mandatory
fmt/log/testing *f format-position literals (recognised structurally: a variadic callee named …f with a string parameter immediately before the variadic) a format string slugs badly ("%v") and a formatting call’s cost is dominated by formatting itself
degenerate slugs — no usable ASCII word content, or a slug of ≤ 3 characters strˢ7 / carry no information; the literal reads better inline. "true" slugs to a healthy four-character true and DOES hoist
the empty literal "" already 0 B (ToArray() of an empty span returns Array.Empty)
composite-literal elements and keys uniform hoisting would emit thousands of fields above the table-building functions and move their allocations out from under a sync.Once into the type initializer. (A standalone index into the same map — table["composite key"] — still hoists)
literals inside func init() run exactly once by construction
package-level var/const initializers — decided on the Go AST position, not the emitted C# shape one-time by nature (a package-level table is emitted into an initᴛ* method body, which a shape-based rule would mistake for an ordinary function)
func literals OUTSIDE a function declaration no FunctionPrefixMarker anchor exists to hoist above
\xHH / high-octal raw-byte literals already diverted to the byte-array-backed @string path
every declaration a [module: GoManualConversion] file or entry owns its emission is redirected to a non-compiled .cs.auto (or replaced by a placeholder comment), so it renders no prefix marker and must never CLAIM a field; its own literals stay inline, and the reconvert gate asserts no hoisted field is ever declared in a .cs.auto
universe builtins (panic, print, copy, unsafe.Slice, …) go/types records a call-site-specific signature for these, but the converter emits each through its own path — panic deliberately keeps the bare interned literal, which is zero-cost until a panic actually fires

Naming. HoistedLiteralMarker (ˢ, U+02E2 — a new symbols.json entry, never hardcoded) suffixes a camelCase slug of the literal’s own content, joined at word boundaries and truncated at ≤ 24 characters. The alphabet is ASCII letters and digits, not unicode.IsLetter: a C# identifier is lexed over UTF-16 code units, so a letter outside the BMP is a surrogate pair and can never appear in one — go/types spells its universe type set "𝓤" (U+1D4E4, category Lu), and a rune-wide slug emitted 𝓤ˢ, a CS1056/CS1519 cascade. An ALL-CAPS word folds whole ("TESTING KEY"testingKeyˢ, "CONTENT-TYPE"contentTypeˢ); touching only its first character would leave tESTINGKEY, which was 9% of the corpus’ hoisted names on the first cut. Distinct literals whose slug collides take a package-wide first-occurrence ordinal (fooˢ, fooˢ2), checked against the package’s declared names and the already-claimed hoist names — performNameCollisionAnalysis walks Go declarations only and never sees a synthetic name. Because every hoisted name ends in ˢ, it can collide with neither a C# keyword nor a Go-derived identifier.

Two orderings the mechanism has to respect.

Initialization order. C# runs static field initializers in textual order within a class PART and in unspecified order across parts, so a package-level var whose initializer transitively reads a hoisted field could observe default(@string) (""). The converter already owns the defense — initOrderOperations relocates dependency-ordered initializers into the generated static constructor, which runs after ALL field initializers — but its graph is keyed on Go variables and cannot see a synthetic field. Every function that reads a hoisted field is therefore registered in that graph, so any package-level initializer reaching one transitively is relocated. Three live corpus instances surfaced immediately: net/http/internal/testcert’s LocalhostKey = testingKey(…) reads two hoisted fields declared later in the same file and would have run strings.ReplaceAll(s, "", ""); internal/profile and runtime/pprof are the other two.

Where the relocation is unavailable the rule takes a second arm: the -tests variant conversion does not run collectMovedInitVars at all (the test project has no package_init.cs emission path, and an internal variant shares the production class, which may already own a static constructor — a second one is CS0111), so there nothing inside a function a package-level initializer can REACH is hoisted at all; those sites keep the inline rendering, and the same literal still hoists from any other use. This is not hypothetical: encoding/pem’s var pemData = testingKey(…) is declared ~300 lines above the testingKey whose two hoisted fields it depends on, ran strings.ReplaceAll(s, "", ""), and left every "TESTING KEY" in place. The corpus compiled clean with that bug present — only running the package’s own Go tests found it.

Two-pass -tests conversion. An internal _test.go file emits into the PRODUCTION package class and can sort BEFORE the production file that owns a field. The test pass’s registry is therefore pre-seeded with the production literal→field map (recomputed by the same collector over the production files, with their manual-conversion flags), and a test file may only REFERENCE a seeded literal, never claim it — that is what prevents CS0102, not name luck. An EXTERNAL <pkg>_test variant carries no production files, so its seed is empty and it claims freely into its own class, which is required: a production field is private to a different class. Production output is byte-identical whether or not tests are converted.

Determinism. File conversion is sequential in sorted-filename order (concurrency was removed for exactly this reason), and names and placement derive only from literal content plus source order, so two runs over the same tree emit the same bytes. Emission itself is a pure substitution at convExpr’s single *ast.BasicLit arm; the decision cannot be made there, because pre-boxing needs every use of a literal and the init-order rule needs the reader set before any file emits.

One interaction is worth naming: applyUntypedConstBoxCast re-applies the (@string) default-type box cast to anything that does not already lead with it, and a hoisted name does not. It now skips a hoisted literal outright — an @string field needs no cast, and re-casting a PRE-BOXED object field would unbox and allocate a fresh box on every evaluation, defeating the hoist at exactly the any-slot sites it targets.

Corpus effect (Go 1.23.1, 302 packages): 3,253 hoisted fields across 467 files, 62 of them pre-boxed. Per function the blocks are small — 1,634 blocks, median 1, p90 4, p99 11, max 61 (net/http’s StatusText, the worst case the design accepted up front). Per file the median is 3 and the max 127, in the 20k-line bundled net/http/h2_bundle.cs. A side effect worth recording: two dead deref-alias prologues disappeared (runtime’s lfnodeValidate, go/typessuspendedCall) because bodyReferencesIdentAsValue is a text test whose own comment names “a string” as a source of spurious matches — the words node and call inside those functions’ message literals were the only textual occurrences keeping the aliases alive. Moving the literal out of the body drops the dead local; a genuinely live alias is still never dropped, since a real value use emits the identifier regardless. (Guarded by the StringLiteralHoisting behavioral test — every row of both tables above, plus slug-collision ordinals, cross-file dedupe, and the init-order case, output-compared vs go run.)

Composite types render structurally ([]*T keeps the pointer)

A slice/array type is rendered structurally in every type-name path: the [N]/[] marker plus the recursively resolved element, never from the go/types string form. The string form is path-qualified ([]*internal/abi.Type), and the cross-package last-segment strip would eat everything before the slash including the pointer marker, silently dropping the ж<> (reflect’s []*abi.Type fields compiled against the WRONG element type). The recursion also resolves lifted anonymous elements and cross-package generic elements:

ptrs := vals.([]*atomic.Int32)
var ptrs = vals._<slice<ж<atomic.Int32>>>();

Guarded by ArrayOfCrossPackageType (the type assert and a var declaration).

A SAME-PACKAGE instantiated generic is rendered structurally for the same reason — the name plus each type argument recursively resolved, never from the go/types string. A cross-package generic already took the structural path (getAliasQualifiedTypeName/getFullyQualifiedTypeName both special-case pkg != v.pkg), but a generic whose OWN type is local while a type ARGUMENT is cross-package fell through to the t.String() form: curve[*repro/sub.Item], whose slash-strip then ate everything before the /including the curve[ header — collapsing the wrapper. crypto/elliptic’s var p224 = &nistCurve[*nistec.P224Point]{…} and its p256Curve struct { nistCurve[*nistec.P256Point] } embed emitted ж<nistec.P224Point>> / ref go.nistec.P256Point> … (a CS1519/CS1526 cascade, ~137 errors across elliptic/ecdh/mlkem768). Both getAliasQualifiedTypeName (the var-type path) and getFullyQualifiedTypeName (the struct-embed field path) now render a same-package generic as Name[args…] with each arg via the same function, so the arguments carry their short, slash-free package-qualified names and the header survives → ж<nistCurve<ж<nistec.P224Point>>>. Byte-identical across the behavioral corpus; an A/B of crypto/elliptic+ecdh shows only wrapper-restorations at every site (var types, adapter ctors, GoImplement attributes, the embed accessor, the unmarshaler array). (Guarded by the CrossPkgUser extension — a same-package Holder[*CrossPkgLib.Sensor] as a var type AND a struct embed, field read/write vs Go.)

A reference-type-pointee pointer parameter uses the nil-check-free .ValueSlot deref alias

The entry deref-alias for a pointer parameter is ref var p = ref Ꮡp.Value. The .Value getter throws NilPointerDereference when the box reports IsNull — for a MANAGED box, m_val is null. That is correct when the pointee is a VALUE type (a null m_val means a genuinely nil pointer). But when the POINTEE is itself a reference type — *error, *[]T, *map[K]V, **T, *func(…), *chan T — the box holds the reference VALUE directly, and that value is legitimately null when it is the zero value (a nil interface/slice/map). The pointer is still a valid, non-nil box (Ꮡ(err)), so establishing the entry ALIAS is a read of the held value, not a dereference of the box: in Go, *(&err) of a nil error yields nil, no panic. .Value’s IsNull check misfires on m_val is null and panics spuriously at function entry — text/scanner’s digits(…, invalid *bool) and, for a reference pointee, text/tabwriter’s handlePanic(err *error) (deferred from Write, whose err is a nil named-return interface) crashed with a nil-pointer panic before recover() even ran. The fix: when the pointee isInherentlyHeapAllocatedType, emit the nil-check-free .ValueSlot accessor (ref var err = ref Ꮡerr.ValueSlot), mirroring namedResultBoxAccessor — a named result of the same type already reads this way. .ValueSlot returns the same real m_val slot as .Value in every non-throwing case, so write-through and non-null reads are byte-behaviorally identical; only the spurious-panic case changes. Corpus-wide the swap touches 49 stdlib files + 10 behavioral goldens, all value-preserving (full behavioral suite Output 0-fail). (Guarded by the PointerToInterfaceParamDeref behavioral test — a *error parameter read through inside a deferred recover/re-panic where the pointee is nil at address-of time; before the fix the entry alias NREs, after it prints the re-panic message, output-compared vs go run.) ⚠ This fixes only the spurious CRASH. A SEPARATE latent defect remains: a non-heap-promoted address-taken named return — Ꮡ(err) boxes a COPY — so *err = … in the deferred handler writes the copy while return err reads the original; text/tabwriter’s tests need that heap-promotion of address-taken named returns before they fully validate.

The value-type nilable case — a genuinely nil *rune/*bool/*int optional-out-param, deref’d only under a body VALUE guard — was handled for a fortnight by a companion call-site nil-argument detection, and is now subsumed by the unconditional nil-deferring entry alias (see A pointer PARAMETER is nil-deferring for exactly the reason a receiver is). The problem it solved is worth keeping on the record, because it is the cleanest demonstration of why an entry-alias policy cannot be an analysis: collectNilSafePtrParams scanned only the body for param == nil/!= nil, so text/scanner’s digits(ch0 rune, base int, invalid *rune) — whose sole deref *invalid == 0 sits behind ch >= max (never invalid != nil) and which is called digits(ch, 10, nil) — kept the strict .Value entry hoist and NRE’d at entry, where Go never dereferences (ch >= max is false on the nil-call path). The remedy was a package-wide pre-pass (collectNilArgPtrParams) recording, per *types.Func, the pointer-parameter positions ever passed the untyped nil at a call site — which worked, but only for SAME-package call sites, because the converter processes one package at a time. A parameter passed nil solely from another package stayed strict and stayed broken; that residual is what .DerefOrNull() closes structurally, and the pre-pass was deleted with the rest of the analysis. (Still guarded by the GuardedNilPointerParamDeref behavioral test — a *int out-param deref’d under an i >= base guard, called once with a real pointer and once with nil; NREs at the entry hoist under either predecessor, matches go run now without one.)

A pointer-element composite literal takes the box for a deref-aliased ident

A bare identifier element of a pointer-element composite literal ([]*CommentGroup{c}) renders the pointer VALUE — the box Ꮡc — not the deref’d receiver ref-local c. Every named pointer parameter is deref-aliased in C# (ref var c = ref Ꮡc.Value), and the bare name is the value alias; the array element type is ж<CommentGroup>, so the alias form was CS0029 (go/ast’s CommentMap.addComment — the sibling append(list, Ꮡc) already took the box through the call-argument pointer arm). The routing mirrors the struct-field pointer arm: the element index is marked argTypeIsPtr, which convExprList turns into the pointer ident context:

list = new ж<CommentGroup>[]{c}.slice();

Gated to bare idents of pointer type — keyed elements (maps) and address-of/composite elements manage their own pointer rendering. Guarded by the PointerParamWalk extension collect (the literal arm and the append arm, aliasing proven by a post-collect write through the original).

A pointer value passed to an any argument takes the box

A deref-aliased pointer passed WHOLE (as an argument, not p.field) to an EMPTY-interface (any) parameter renders the pointer VALUE — the box Ꮡp — not the deref’d value alias p. Go boxes the pointer into the interface, so dropping the box stores the pointed-to VALUE and loses pointer identity: a later x.(*T) assertion (rendered ._<ж<T>>()) then finds a bare T and panics (“interface conversion: … is T, not *T”). This is fmt’s own sync.Pool round-trip — func (p *pp) free() { … ppFree.Put(p) } (Put’s parameter is any) feeding newPrinter’s ppFree.Get().(*pp) — which crashed the SECOND time through the pool, blocking every multi-call fmt program. Both a pointer RECEIVER and a plain *T PARAMETER take the box:

func (p *pp) free()  { poolPut(p) }   // p is *pp (pointer receiver); poolPut(x any)
func keep(q *pp)     { poolPut(q) }   // a plain *T parameter, same shape
internal static void free(this ж<pp> p) {
    ref var p = ref p.Value;
    
    poolPut(p.OrTypedNil());          // NOT poolPut(p) — a pp VALUE loses pointer identity
}
internal static void keep(ж<pp> q) {
    poolPut(q.OrTypedNil());
}

(The OrTypedNil() suffix is the other half of the same boundary — see A pointer crossing into an interface carries its static type, which generalized this arm from the call-argument slot to every empty-interface slot.) This mirrors the composite-literal element arm above: the argument index is marked argTypeIsPtr, which convExprList turns into the pointer ident context, so convIdent emits the parameter box (Ꮡp) or the current method’s direct-ж receiver box. It fires ONLY for the empty interface — a NON-empty interface already routes the pointer through its *T→interface adapter (interfaceTypes), and the two arms are mutually exclusive. A pointer LOCAL is excluded (it already holds its box directly — the bare name IS the box), an unsafe.Pointer argument is excluded (not a *types.Pointer), and the treatment fans out across a variadic ...any. The receiver form reaches through a closure too — Ꮡs.Value.d.note(Ꮡs) for s.d.note(s) inside a nested lambda (the database/sql (*Stmt) shape). Guarded by PointerValueToInterfaceArg (a minimal sync.Pool-shaped free list round-tripping a *pp via both a pointer receiver and a pointer param, each .(*pp)-asserted after the any hop — the 2nd pool Get panicked before the fix) and the NestedLambdaReceiverField receiver-in-closure case.

Appending to an interface-typed slice casts the element

A value appended to a []Iface slice whose type is not already the interface – a pointer rendering as the *T-to-interface adapter ctor, or a raw struct value – leaves both golib append overloads applicable (append<T>(ISlice, params T[]) infers the concrete/adapter type; append<T>(slice<T>, params Span<T>) infers the interface – CS0121). The converter casts such elements to the element interface type:

pack = append(pack, (Animal)(new CatжAnimal((new Cat(nil)))));
pack = append(pack, (Animal)(new Dog(nil)));

An already-interface-typed element stays bare. The empty interface (any) element type is affected identically and takes the same cast — append(args[:len(args):len(args)], c.output) with args []any and c.output []byte infers T=[]byte on the ISlice overload but T=any on the slice<T> overload (testing’s flushToParent, CS0121), and appending a scalar (append(anys, 5)) is the same shape; the differing element is cast to any so both overloads agree:

args = append(args.slice(-1, len(args), len(args)), (any)(c.output));

Guarded by InterfaceCasting (non-empty interface) and AppendUntypedConst (the empty-interface []byte-into-[]any and scalar-into-[]any cases).

A nil element appended to a slice casts to the element type

A bare nil appended as a single element – append(b.lines, nil) on [][]cell – renders nil as default!, which C# overload resolution binds to append’s params parameter as the whole null array (a non-expanded params call), appending ZERO elements rather than one nil element. This silently no-ops the grow: text/tabwriter’s addLine never extended b.lines, so every terminateCell indexing b.lines[len(b.lines)-1] panicked with index [-1]. The interface (error) and named-composite branches above already cast a nil element (its untyped-nil type differs from the element type), but an unnamed nillable element type – slice/map/pointer/chan/func – matched no branch and emitted bare. The converter now casts any untyped-nil element to the slice’s element type, forcing single-element binding:

lines = append(lines, (slice<nint>)(default!));       // []int   element
maps  = append(maps,  (map<@string, nint>)(default!)); // map     element
ptrs  = append(ptrs,  (ж<nint>)(nil));                 // pointer element (nil renders in pointer context)

A nil element is only ever valid when the element type is nillable, so the cast target always exists. Spread appends (append(dst, src...)) are excluded (the existing Ellipsis.IsValid() guard). Guarded by the AppendNilSliceElement behavioral test (slice/map/pointer element types, output-compared vs Go).

A struct-literal interface field takes a pointer element’s adapter

A composite struct literal whose field is an INTERFACE type, initialized with a POINTER element whose pointer-receiver method set satisfies that interface, must record and route the same *T→interface adapter a call argument does — &handlerWriter{l.Handler(), &logLoggerLevel, capturePC} (log/slog SetDefault), where field level is Leveler and *LevelVar implements Leveler via a pointer-receiver Level(). The struct-field interface routing (checkStructFields) recorded/routed a NAMED VALUE element that satisfies the field (DecodingError{InvalidIndexError(idx)}) but matched only a *types.Named element, so a POINTER element fell through: no GoImplement<LevelVar, Leveler>(Pointer = true) was recorded, and the box ᏑlogLoggerLevel was passed bare to the interface-typed constructor parameter (CS1503). The detection now takes the concrete satisfying type from the element OR the pointee of a POINTER element (types.Implements tested on the element’s own pointer method set, the non-interface guard tested on the pointee), so a pointer element records and routes exactly like the value case:

new handlerWriter(l.Handler(), new LevelVarжLeveler(logLoggerLevel), capturePC)
// [assembly: GoImplement<LevelVar, Leveler>(Pointer = true)]  -- in package_info.cs

The record flows through the existing pointer-target arm of convertToInterfaceType (the ж<T>-wrapped name unwraps to GoImplement<T, Iface>(Pointer = true), and the render wraps the box in the generated TжIface adapter), so a same-package local (streamWriterio.Closer in net/http/fcgi) and a foreign pointee (*ast.SelectorExprast.Expr, *BasicType, *FuncObject in go/types) route through their local or foreign adapters uniformly. Positional and keyed literals both resolve their field (a keyed element renders d: new SettingжDescriber(Ꮡs)); an already-interface element and a value element are unchanged. (Guarded by the PointerInterfaceStructField behavioral test — a pointer-receiver-only implementer placed in an interface-typed struct field, positional via an addressed global and keyed via an addressed local, output-compared vs Go.)

The struct-field interface routing also fires on an ELIDED element composite

The routing above lived only on the TYPED composite path (checkStructFields, reached from convCompositeLit’s *types.Named/*types.Struct arms). An elided element composite — the inner {v0, v1, …} of a []struct{…}{…} / map[K]struct{…}{…} / [N]struct{…}{…}, which drops the repeated struct type and resolves it by inference (compositeLit.Type == nil) — took the separate target-typed new(…) constructor branch, which emitted its element values through convExprList with no interface recording or routing at all. So a struct field of interface type in such a literal was passed bare: a POINTER form lost its new TжIface(…) adapter wrap, and a VALUE form whose concrete was used only in the elided literal (never converted to the interface anywhere else) was never GoImplement-recorded, so no partial struct T : Iface was generated for it. Both compile to CS1503. This is exactly errors’ wrap_test, whose []struct{ err error; … }{ {&poser{…}, …}, {errorUncomparable{}, …} } produced 17 cannot convert from 'ж<poser>' / 'errorUncomparable' to 'error' at the new(…) sites while the sibling multiErr{poser} slice-element cast (a different path) wrapped its poser correctly.

The interface-field record+route loop was extracted from checkStructFields into a shared recordStructFieldInterfaceCasts(compositeLit, structType, callContext) and is now called from both the typed path and the elided path (against the inferred *types.Struct), so an elided struct composite routes its interface fields identically:

new(new poserжerror(poser), err1, true)                      // *poser  → error  (Pointer = true)
new(new errorUncomparableжerror((new errorUncomparable(nil))), )  // *errorUncomparable → error
new(new errorUncomparable(nil), )                            // value form: partial struct : error boxes
// [assembly: GoImplement<poser, error>(Pointer = true)] + <errorUncomparable, error>[(Pointer = true)]

The extracted logic is byte-for-byte the proven typed-path logic (same keyed-vs-positional field resolution, same value/pointer method-set satisfaction test), so it inherits every guard the typed path already carried (the gif keyed-field bogus-record avoidance, the types.Implements pointee test). An isolated A/B full-reconvert of a production cross-section (fmt, errors, net/http, encoding/json, flag, go/types, os, time, text/template — 172 .cs) shows zero production emission change: the pattern is overwhelmingly a test-code shape, so the fix is inert for ordinary packages and only realizes the previously-uncompilable test literals. (Guarded by the ElidedStructInterfaceField behavioral test — a pointer-receiver *pointerErr and a value-receiver valueErr, each used only in an elided []struct{ err error; … }{…}, output-compared vs Go; the pre-fix converter emits the bare box / bare value and fails CS1503 on both.)

And on the elided POINTER element composite (2026-07-31). There are three composite paths, not two: []*struct{…}{{…}, …} — Go’s shorthand where the & is implied — has its own arm in convCompositeLit, reached before the elided-struct arm above and emitting Ꮡ(new T(…)) rather than the target-typed new(…). That arm marked any field literals but never called recordStructFieldInterfaceCasts, so a concrete element in an interface slot again reached the generated constructor bare. net ip_test’s []*struct{ in IP; str string; byt []byte; error } — an embedded error field — handed a ж<AddrError> to the error parameter with no AddrErrorжerror wrap (CS1503). The arm now makes the same record+route call its two siblings do:

(new ipStringTests1(new IP(), "?0123456789abcdef"u8, default!,
    new net_test_package.net_AddrErrorжerror((new AddrError(Err: , Addr: )))))

The tell is worth carrying forward: each of the three paths grew its own field-marking sequence (markStringFieldLits / markAnyFieldLits / recordStructFieldInterfaceCasts) independently, and the one that fell behind is the one nobody had a failing case for — the same shape-versus-its-pointer- composition asymmetry as An anonymous struct lifts from ANY depth of its declared type. Behavioral CNR is byte-identical across the whole corpus: like its sibling, this is a test-code shape. (Guarded by the ElidedStructInterfaceField extension — a []*struct{ want string; error } whose interface field is embedded, carrying both a pointer-receiver and a value-receiver implementer.)

A keyed element’s interface target is the composite’s own SLOT, never the LHS variable’s type

A composite literal assigned to an interface-typed variable converts to that interface as a WHOLE — visitAssignStmt’s convertExprToInterfaceType (and visitValueSpec’s convInterfaceDeclValue for a declaration) wraps the finished literal in its adapter. convKeyValueExpr also consulted the LHS variable’s type (context.ident) for each keyed element, so the interface was applied a SECOND time, to values whose real slot is not an interface at all. On a map whose element type is a POINTER that is silently destructive: the element renders correctly as Ꮡ(new T(…)), the spurious *T → Iface conversion adds the deref prefix, and convertToInterfaceType’s “~ of an immediate Ꮡ(…)” collapse then hands back the bare struct — a map[K]*T slot holding a VALUE (CS0029).

os’s TestCopyFS is the reached case: fsys is an fs.FS and the test reassigns it

fsys = fstest.MapFS{"william": {Data: []byte("Shakespeare\n")}}   // map[string]*MapFile

which emitted ["william"u8] = new fstest.MapFile(Data: …) instead of Ꮡ(new fstest.MapFile(…)), ×5. The same literal in a var declaration, as a call argument, or assigned to its own concrete type was always correct — only the reassignment path carried the LHS type down into the elements, which is the tell that the LHS was never the right source of truth.

The element’s target is now the composite’s own value slot (valueSlotType, already computed for the MapSource/StructSource untyped-constant boxing just above), with the LHS ident kept only as the FALLBACK for a composite that does not state its slot type here — the sparse-array shape it was originally added for. A struct FIELD of interface type keeps its single conversion through structFieldIfaceType. An interface-VALUED container (map[K]Iface{k: v}) still converts every element, now through the slot rather than the variable, so the two agree by construction. Behavioral CNR is byte-identical across the corpus — the shape needs a named container of pointers reassigned to an interface variable, which the behavioral corpus did not contain. (Guarded by the ElidedPtrElemIfaceAssign behavioral test: a map[string]*Item and a []*Item, each declared into, reassigned into, and passed into an interface, with a write through a stored element pointer proving the map holds the same object; plus an interface-VALUED map as the live control for the preserved conversion.)

A GoImplicitConv record needs at least one LOCAL operand

ImplicitConvGenerator realizes a recorded conversion as a partial struct <name> inside THIS package’s class, so the record has to name a type this package declares. The generator already relocates the host when exactly ONE side is foreign (its “foreign SOURCE via a local alias” / “foreign TARGET via a qualified reference” arms), and the converter’s aliased-numeric arm swaps target and argument for the same reason — to anchor the record on the local operand. With NEITHER operand local the swap merely picks the other foreign one and the generator has nothing to extend: it declares partial struct <simple name> locally, a PHANTOM type of that name, and the operator body’s src.Value does not exist (CS1061).

os reaches it from os_windows_test.go’s privilege helper, syscall.CloseHandle(syscall.Handle(t)) over a syscall.Token — both operands in syscall. Both the struct-conversion and the aliased-numeric arms of checkForImplicitConversion now require conversionRecordHasLocalOperand, stated once as the property rather than per-arm. Declining costs nothing: the call site already emits the explicit ((syscallꓸHandle)(uintptr)t) cast chain, which needs no generated operator, and an operator between two foreign types could not be hosted in either of their assemblies from here in any case. Behavioral CNR is byte-identical. (Guarded by the ForeignPairNumericConv behavioral test — a sibling library declaring two named numerics and never converting between them, converted across in main, with the foreign→local and local→foreign directions as the live controls for the records that are still needed.)

Named-string wrapper surface (indexing, sub-slicing, span bridge)

A named type over string is indexed and sub-sliced in Go (tag[i], tag[i:j] – reflect StructTag.Get), but C# indexing never applies user-defined conversions. The InheritedType template therefore forwards the @string surface on every named-string wrapper: byte this[int] / byte this[nint] indexers, a Range indexer returning the WRAPPER (a Go sub-slice of a named string keeps the named type), nint Length for len(), and an implicit ReadOnlySpan<byte> operator so u8-literal comparisons and assignments bind. Guarded by NamedStringConversion.

A :=-declared string local keeps its named type and its heap box

A string-underlying local declared with := takes its EXPLICIT declared type through the same general declaration path every other type uses (never var — a u8 literal would infer ReadOnlySpan<byte>). The old dedicated string branch hardcoded @string as the declared type, which (a) DISCARDED a named string type — go/types check.go’s fileVersion := asGoVersion(…) declared its goVersion locals as @string, so the goVersion extension methods isValid()/cmp() no longer bound (CS1929 ×4) — and (b) BYPASSED the escape-analysis heap-box check, so cause := "" followed by &cause emitted an unboxed local while the call site referenced the nonexistent box Ꮡcause (CS0103):

goVersion fileVersion = asGoVersion((~@file).GoVersion);   // named type preserved
ref var cause = ref heap<@string>(out var cause);         // escaping local heap-boxes
cause = ""u8;

A plain, non-escaping string local emits exactly as before (@string s = "…"u8; — the general path’s explicit-type arm resolves to @string). The same explicit-type routing applies in the for-init tuple-declaration form. (Guarded by the NamedStringDefine behavioral test — a named-string := with methods called on the local, an escaping cause := "" written through its pointer, and a plain string local, output-compared vs Go.)

A typed const of a named string type keeps the named type

The CONST-DECL arm of the same materialization family: visitValueSpec’s string-constant emission hardcoded @string, so net/http pattern.go’s const equivalent relationship = "equivalent" (with type relationship string) emitted internal static readonly @string equivalent = … — and every comparison rel == equivalent was then ambiguous, because the [GoType("@string")] wrapper and @string convert implicitly BOTH ways (CS0034 ×20 across pattern.cs). A typed string const now keeps its named type, initializing through the wrapper’s ReadOnlySpan<byte> implicit operator (the StringSurfaceMembers u8 bridge):

internal static readonly relationship equivalent = "equivalent"u8;

Function-body typed string consts take the same form (relationship localRel = "moreSpecific"u8;); an UNTYPED string const keeps @string (its type is not a *types.Named). Full-stdlib footprint: net/http pattern.cs, traceviewer’s ViewType consts, and regexp/syntax parse.cs.

The u8 form is required for EVERY value expression, not just a bare literal. The rule above got its u8 from the literal path, which fires only when the spec’s value expression is an *ast.BasicLit — i.e. the const x T = "…" spelling. Go’s other spellings put a different node there: a conversion (const opLoad = mapOp("Load")) is a CallExpr, and a folded concatenation (prefix + "Delete") is a BinaryExpr. Those fell to the folded-value path and emitted a plain C# string literal, from which the [GoType("@string")] wrapper is two user-defined conversions away (string@string→wrapper) — which C# forbids, so the whole declaration group failed (CS0029 ×9 on sync map_test.go’s mapOp const block). The folded value now takes the same u8 rendering whenever the declared type is named:

const opLoad  = mapOp("Load")            // conversion — CallExpr
const opStore = mapOp("op" + "Store")    // folded concatenation
internal static readonly mapOp opLoad = "Load"u8;
internal static readonly mapOp opStore = "opStore"u8;

A RAW (backtick) value has no u8-suffixable verbatim form, so it takes an explicit (@string) cast instead — also a single conversion. A plain @string const is untouched (string@string is already single-step), which is what keeps the folded-untyped-const emission byte-identical corpus-wide. (Guarded by NamedStringConsts — package-level and local typed consts compared against values and each other, a method called on a const, the conversion and folded-concatenation spellings at both package and function scope, and an untyped const staying plain, output-compared vs Go.)

A grouped var spec with one multi-result call deconstructs

A grouped var (name, offset, abs = t.locabs() ...) spec is not a :=, so the assignment tuple machinery never saw it – the per-name path assigned the WHOLE result tuple to the first name and silently DEFAULTED the rest (time appendFormat read a zero abs; a silent-wrongness class beyond the CS0029 that exposed it). Function-local specs now emit the C# tuple deconstruction, matching the := form; package-level specs use the once-evaluated hidden-field component reads:

var (ln, ls) = pair();

Guarded by GlobalTupleVarDecl (both levels, with a call-count check proving single evaluation).

The function-local gate asks whether a name has a BOX, not whether it “escapes” (2026-07-31). That branch is gated to specs no name of which needs a ref heap<T> box declaration, and it read the raw identEscapesHeap flag — which the escape analysis blanket-sets for every inherently heap-allocated local (pointer, slice, map, chan, interface, func), because those are already references and get no box unless their address is genuinely taken. So the gate rejected specs that are entirely plain, and every tuple with an interface or func result fell back to the very per-name path this branch exists to replace:

context.Context ctx = context.WithCancel(context.Background());   // the WHOLE tuple  (CS0029)
Action cancel = default!;                                          // silently defaulted

identHasHeapBox is the predicate that answers the gate’s actual question, and it is what the branch now calls. This is the trap paramAddressTakenNeedsBox already documents from the other side — a verdict the box gate then refuses leaves identEscapesHeap set with no box behind it — and it stayed hidden because (int, string)-shaped tuples, the ones anyone reaches for when probing, work fine. net’s var ctx, cancel = context.WithCancel(context.Background()) is the corpus site. (Guarded by the GlobalTupleVarDecl extension — a local var si, fi = ifaceAndFunc() returning an interface and a func, both read back.)

string() of an untyped constant reference hops through the default type

string(utf8.RuneError) renders the argument as its cross-package static readonly Untyped* wrapper, from which @string has no conversion (CS0030). The conversion hops through the constant’s DEFAULT Go type first – exactly Go’s conversion semantics; a plain literal is already a C# constant and keeps its direct form:

fmt.Println("a" + ((@string)(rune)CrossPkgLib.Sep) + "b");

Guarded by CrossPkgUser (string(CrossPkgLib.Sep)).

A := from a named untyped constant materializes the default type

codepoint := unicode.ReplacementChar must not declare with var: the constant renders as its static readonly Untyped* wrapper (UntypedInt/UntypedFloat/UntypedComplex), so var binds the LOCAL to the wrapper type instead of Go’s inferred default type, and a later Go conversion like string(codepoint) fails (CS0030 — no UntypedInt@string form; go/types conversions.go). The declaration materializes the Go-inferred default type instead — exactly Go’s := typing:

rune codepoint = replacementChar;    // NOT `var codepoint = …` (binds UntypedInt)
float64 factor = scale;

The gate is an Ident/Selector RHS resolving to a *types.Const of untyped NUMERIC kind (int is already routed to the explicit nint form, and string consts to the explicit string path); literals and computed constant expressions render as plain C# literals and keep var. Applies in both the single-declaration and the mixed-statement paths. (Guarded by the UntypedConstDefine behavioral test — untyped rune and float package constants :=-bound then converted/multiplied, output-compared vs Go.)

A computed untyped float constant materializes at its destination’s float width

A named untyped constant can still need its Untyped* wrapper because another use demands a different type. When that name participates in a computed float constant, C# must not evaluate the expression through the wrapper’s arithmetic operators:

const repetitions = 100000
var loopBound int = repetitions
mean := .5 * repetitions
var quarterMean float64 = .25 * repetitions

The wrapper form makes C# overload resolution prefer UntypedInt.operator*, converting .5 or .25 to an integer and truncating it to zero before the result reaches the float local. The converter instead folds the exact Go constant expression once at the destination’s resolved float32 or float64 width. An explicitly typed destination is visible on the expression itself; for a new := local, go/types keeps the RHS untyped and records the default type on the declared identifier, so the declaration edge supplies that width. Both paths reuse the same named-constant fold and leave bare references, non-float constants, and expressions without a wrapper-emitted named constant unchanged. Guarded by UntypedConstDefine; hash/maphash’s 100,000-sample SMHasher avalanche bounds are the corpus witness.

complex() over a NAMED untyped constant pins the element width

golib’s complex builtin is overloaded on element width — complex(float32, float32) => complex64, complex(float64, float64) => complex128 — and UntypedFloat converts implicitly to both. C# then applies its better-conversion-target rule, which prefers the narrower target (float32 converts to float64, not the reverse), so a complex128 the Go checker typed as such was silently constructed at float32 width:

const maxFloat32 = 3.40282346638528859811704183484516925440e+38
over := complex(maxFloat32*2, maxFloat32*2)     // complex128, 6.805646932770577e+38
var over = complex((float64)(maxFloat32 * 2D), (float64)(maxFloat32 * 2D));

Without the casts over is (+Inf+Infi) — and encoding/gob’s TestOverflow then found nothing out of complex64’s range to reject, because float32FromBits accepts +Inf at either width. A LITERAL argument never had the problem: the untyped-const analysis records the call’s element type as the argument’s context and convBasicLit renders the F/D suffix from it (complex(1.5D, 2.5D)). A named untyped const (Δmath.MaxFloat32), or a constant expression over one, renders as the UntypedFloat symbol and cannot carry a width — so exactly those calls pin their untyped arguments explicitly, at the element width Go’s own typing gives the call (complexCallElementType, resolving an untyped-complex-constant call through its recorded context and Go’s complex128 default). A MIXED call needs nothing and gets nothing: complex(g, half) with g a float64 was always unambiguous, since float64 has no implicit conversion to float32.

The rule cannot be expressed from golib’s side, and the attempt is instructive. Naming the untyped pair explicitly (complex(UntypedFloat, UntypedFloat) => complex128) makes every MIXED call ambiguous — complex(0D, gHalfPi) has the float64 overload better on the first operand and the untyped one better on the second, so neither wins (CS0121). Completing all four width pairings does not rescue it either: UntypedFloat converts implicitly in both directions with float32 and float64, so for an operand that is neither — complex(7/2, 0D), an int — no candidate is strictly better and the ambiguity simply moves. Overload resolution has no way to say “prefer the width the call was typed at”; only the emitter knows that.

Corpus footprint: zero. The trigger is a named-untyped-const operand, and no complex() call in the standard library (math/cmplx included) has one — every corpus site is either width-pinned literals or has a typed operand. Guarded by the extended ComplexConstContext (the overflow pair, its float32-range question, a named-untyped-const pair in both a complex128 and an explicit complex64 context, and the mixed call that must stay unchanged; neuter-proven — with the arm removed the guard’s over prints (+Inf+Infi) and over-fits-float32 true where Go says false).

A non-escaping string([]byte) local emits the stack-string sstring

Go elides the copy in s := string(buf) when s does not escape and buf is not observed to change, letting s alias buf. @string (a heap byte[] wrapper) cannot do this — every string([]byte) is an allocation + copy — which is the dominant cost the PerfString benchmark measures. The converter therefore emits, for the provably-safe case, a stack-only sstring (a readonly ref struct over ReadOnlySpan<byte>) that VIEWS the source with no allocation: sstring s = ((sstring)buf); instead of @string s = ((@string)buf);. Where the string escapes, the implicit sstring@string conversion copies the bytes to the heap at that boundary.

The escape pass (markSStringEligible) records the verdict; it is deliberately conservative — the MVP’s safest idiom only. A local is eligible iff: it is the built-in string type bound by a single s := string(x); x is an UNNAMED []byte (a []rune→string must UTF-8-encode — an allocation, no view; a named []byte would need a two-hop cast C# will not chain); it does not escape by any channel the escape analysis detects; every use is a safe read — a len/cap argument, a byte index s[i], or a comparison against a string literal OR a plain-string operand (a variable or field, s == want) — so anything else (passed to a function, stored, ranged, concatenated, RETURNED, reassigned) disqualifies it; and the source is never written except at its own declaration. (The comparison operand may be any plain-string expression, even a call, because the whole-function “never written” guard already means the source cannot change; only the built-in string type is allowed on the other side — a NAMED string type has no operator against sstring, so it stays @string.) Emission is two coordinated sites: convCallExpr retargets the conversion cast to sstring (after the Go→C# name map) under a transient flag; and visitAssignStmt declares the explicit type as sstring and sets that flag around the RHS. The comparison literal KEEPS its "…"u8 ReadOnlySpan<byte> form: sstring has zero-allocation comparison operators against ReadOnlySpan<byte>, so s == "x"u8 compares the backing spans in place. This is what makes the win real — rendering the literal as a plain C# string would force a UTF8.GetBytes allocation on every comparison, and @string == "…"u8 allocates the literal-as-@string each time (a copy neither the JIT nor Native AOT elides); the sstring form is the only zero-allocation one. Measured: the comparison idiom (string(buf) == "…") runs ~12× faster than @string on the JIT and ~11× faster on Native AOT.

Because sstring is a ref struct, the escapes the predicate does NOT enumerate (storing into a field/ array/map, boxing to an interface, channel send, closure capture) are C# COMPILE errors, not silent bugs; the two vectors that would be silently wrong — escape via return and mutation of the source buffer — are guarded explicitly.

A second, broader case needs no escape analysis at all. An UNNAMED string(x) temporary that is an operand of a comparison is created and consumed within the single comparison expression, so it cannot escape; it is emitted as (sstring)x (markSStringComparisonConversions, keyed per-*ast.CallExpr) as long as the OTHER operand cannot mutate x before the view is read. Three safe shapes qualify (sstringOtherOperandSafe):

It stays @string when the other operand could mutate the source before the compare — a function call (string(a) == next(): Go’s string(a) is a copy taken before next() runs, but a stack view would be read only at the ==, after next() could have written a) — or when it is a NAMED string type (no operator against sstring). This byte-signature / header-check idiom is by far the most common string([]byte) pattern in the stdlib: the literal form alone reaches ~23 sites, and the plain-string-operand and two-conversion forms extend it further across crypto/* (md5·sha1·sha256·sha512, comparing against the magic gob-stream prefix), crypto/tls (downgrade-canary checks), hash/*, go/internal/*importer, html/template, and more.

The mixed comparison also widens the named-local case above: s := string(x) compared against a string variable or field (s == want, s == cfg.name) is now eligible, not only s == "literal".

A switch string(x) { case … } is the same comparison family in statement form (markSStringSwitchConversions). A Go string switch ALWAYS lowers to a single temp assigned the tag value, then compared against each case label with == — an if/else chain, never a C# switch and never the constant-pattern (is) form, because string constants render as static readonly @string (not a C# const) and literals as "…"u8, neither of which is a C# case constant that a ref struct could be the subject of. So var exprᴛN = ((sstring)x) infers the stack string and every exprᴛN == label binds a zero-allocation operator (span for a u8 literal, the mixed operator for an @string const/variable). Because the tag is evaluated exactly ONCE into the temp, the only requirement is that no case label can mutate x before the view is read — every label must be sstringOtherOperandSafe (a literal, a pure read, or another conversion — never a call, which is rejected, and never a named string type, which has no operator). This covers the common binary-format-detection idiom switch string(magic) { case elfMagic: … }, and applies both to an unnamed tag (switch string(x)) and to a named local used as the tag (s := string(x); switch s { … }, where the tag read is added to the named local’s safe-use set).

Concatenation (string(x) + suffix) is the same operand family in a + expression. A Go string concatenation always allocates a fresh result, so the result is a heap @string that may itself escape — only the operand is a stack value, and the win is skipping the intermediate ((@string)x) copy of it. golib’s sstring gained operator+ overloads (against @string, another sstring, a ReadOnlySpan<byte> u8 literal, and a plain C# string, both operand orders, all returning @string) that block-copy the operand span straight into the single result buffer instead. The plain-string overload resolves an otherwise-ambiguous string + sstring (both convert implicitly to the other): a literal in an object/vararg concat context renders without its u8 suffix (the converter suppresses it), so panic("incorrect mantissa: " + string(hm)) (math/big) becomes "…" + ((sstring)hm) where "…" is a plain C# string — the explicit overload makes it an exact match rather than a CS0034 ambiguity (mirroring why the comparison form keeps its literal as u8). A string(x) operand of a + is emitted as sstring under the same rules as a comparison operand — markSStringBinaryOperandConversions (formerly …ComparisonConversions) now also matches token.ADD, and requires the other operand to be mutation-safe (a literal, pure read, or another conversion — never a call); a named local used in a concatenation (s := string(x); s + suffix) is likewise added to sstringUsesAreSafe. string(a) + string(b) becomes ((sstring)a) + ((sstring)b), saving both operand copies.

A third refinement is an optimization, not a widening of eligibility: loop-invariant / repeated-conversion hoisting. When the same eligible string(x) over a never-written source is emitted repeatedly — several comparison operands, or one inside a loop — the inline ((sstring)x) re-materializes the view at every use, and the JIT will not hoist a ref struct view out of a loop (measured: a non-throwing MemoryMarshal.CreateReadOnlySpan golib view, added so the ToSpan bounds check could not block loop-invariant-code-motion, made zero difference and was reverted — the fix must be converter-level). A per-FuncDecl pre-pass (planSStringHoists) instead lifts each such group to ONE sstring <temp> = ((sstring)x); at function scope and rewrites every use to the temp (convCallExpr returns the temp name for a lifted *ast.CallExpr; visitBlockStmt injects the decl before the group’s anchor — the first top-level body statement that contains a use). The safe gate is strong and needs no liveness analysis: the conversion operand must be a bare identifier x (never a sub-slice/index — string(buf[:7]) and string(buf[8:12]) are distinct views that must not share one temp), and that x must be a plain function-local or parameter that is NEVER written in the body (objectIsWritten == false), declared before the injection point, with no use inside a nested func literal (a ref struct cannot cross a closure boundary — that is a C# compile error, so the gate keeps the impossibility loud). Worth doing only when the conversion is genuinely repeated — ≥2 uses, or ≥1 use inside a loop — so a lone comparison stays inline. Real Go-1.23 stdlib sstring sites are mostly single comparisons where this is a no-op, so it changes few-to-zero stdlib goldens (the Go-1.23 reconvert hoists zero sites — the one candidate, net/http’s is408Message, is string(buf[:7])/string(buf[8:12]), distinct sub-slices the bare-identifier gate keeps inline); the win is targeted at loop/tokenizer patterns — a scanner comparing string(buf) against several keywords — where a clean back-to-back A/B took PerfStringView from ~4.8× → ~3.0× Go on the JIT (35.9 → 22.5 ms) and ~4.5× → ~1.9× on Native AOT (34.4 → 14.1 ms). That is about the practical floor within .NET: a decomposition micro-benchmark confirmed the sstring == operator itself adds zero over a raw span compare — the whole recoverable cost is the per-use view reconstruction, and the residual is inherent (SequenceEqual’s per-call setup on a tiny buffer vs Go’s inlined memcmp).

Guarded by the SStringElision behavioral test — the eligible cases (two eligible locals, an unnamed comparison operand, two repeated-conversion groups that each hoist to a single reused sstring temp — one in a loop, one straight-line — plus the mixed-comparison additions: a named local compared against a string variable and against a struct field, and two string(bytes) conversions compared directly; plus the switch additions: a switch string(x) with literal cases, a named local as the switch tag, and a magic-constant switch whose case labels are named @string consts; plus the concatenation additions: a named-local s + suffix, an unnamed string(x) + literal and + variable, two conversions concatenated, and a concat into an object context — fmt.Sprint("v=" + string(b)) — that exercises the plain-string operator+) emit sstring; source-mutated, print-escaped, and returned locals, a compare-against-a-function-call, a switch with a function-call case label, and a concat with a function-call operand stay @string — asserting emitted forms and byte-identical Go/C# stdout. Remaining phases (unnamed conversions passed to non-retaining callees / used as map keys, and a precise per-iteration liveness guard that would reach the PerfString loop) are deferred; see docs/Roadmap.md.

Maps and Channels

Go maps and channels convert to the golib map<K,V> and channel<T> structures. make becomes a constructor; channel send/receive use the runtime operators:

m := make(map[string]int)
c := make(chan int, 3)
var m = new map<@string, nint>();
var c = new channel<nint>(3);

Map reads honor Go’s nil-map and comma-ok semantics (see Nil and Zero Values and Multi-Result Values and Comma-Ok Forms).

m[string(b)] — the map-READ key does not copy (tmpstring)

The Go compiler special-cases m[string(b)]: because a map lookup hashes and compares its key but never retains it, the []bytestring conversion’s result provably does not outlive the index expression, and the copy is skipped (runtime.slicebytetostringtmp). The converted C# paid that copy on every probe — one backing byte[] per call — which is exactly the allocation net/textproto.TestCommonHeaders’ want-ZERO testing.AllocsPerRun assert measures over canonicalMIMEHeaderKey’s common-header probe (L11). The converter now recognizes the same shape and emits golib’s tmpstring(b) — a TRANSIENT @string windowing the slice’s live backing through @string.TransientAliasOf, zero allocation:

if v := commonHeader[string(a)]; v != "" { return v, true }
v, ok := m[string(b)]
@string v = commonHeader[tmpstring(a)]; if (v != ""u8) { return (v, true); }
var (v, ok) = m[tmpstring(b), ];

The scope is deliberately EXACTLY the shape whose safety Go’s own optimization proves (mapReadTmpStringKey, convIndexExpr.go): a map index in rvalue position — plain or comma-ok — whose key type is the PREDECLARED string and whose key expression is a conversion to predeclared string over a plain []byte (element exactly basic uint8). Everywhere the string ESCAPES the copying conversion stays: an assignment target (m[string(b)] = v stores the key — emitted m[((@string)b)] = v), delete(m, string(b)), the function’s own return string(a) paths, a named-string key type, a named-over-byte element. Compound assignments and ++ mark the index an assignment target, so they keep the copy for both their read and write halves. (Guarded by the MapStringBytesLookup behavioral test — hit/miss/comma-ok probes through a mutating slice, a sub-slice operand, and the store-then-mutate case proving the STORED key copied — and by GolibTests.AllocationCounterTests.TmpStringMapProbeChargesNothing, which pins the zero charge in both units.)

The NIL map key

Go’s map accepts nil as a key whenever the key type can be nil — map[any]V, map[error]V, map[*T]V, a named-interface key — and that entry is an ordinary entry: it reads, comma-oks, overwrites, deletes, counts toward len, is visited by range, is dropped by clear, appears in a composite literal, and copies through maps.Clone. The converter renders it as default!, so the Go and C# sides line up member for member:

m := make(map[any]string)
m[nil] = "nil-key"
v, ok := m[nil]
delete(m, nil)
lit := map[any]int{nil: 1, "b": 2}
var m = new map<any, @string>();
m[default!] = nilKeyˢ;
var (v, ok) = m[default!, ];
delete(m, default!);
var lit = new map<any, nint>{[default!] = 1, [(@string)"b"u8] = 2};

golib’s map<TKey, TValue> wraps a Dictionary<TKey, TValue>, which rejects a null key with ArgumentNullException before its comparer is ever consulted — there is no comparer to teach. So the nil entry gets a slot of its own: the backing store is a private Dictionary<TKey, TValue> subclass carrying HasNilKey + NilKeyValue, and every member of the map surface routes a null key to that slot (indexer get/set, Set, comma-ok, TryGetValue, ContainsKey, Add, Remove, Clear, Count, both enumerators, Keys/Values, the copy constructor behind CloneMap, and ToString). range yields the nil entry ahead of the buckets — Go’s range order is unspecified and deliberately randomized, so the position is free, and every map without a nil key stays on the dictionary’s own enumerator unwrapped.

Two design points are load-bearing. First, the slot lives on the store, not on the struct: a Go map is a reference type, so every copy of a map<K,V> value must observe the same nil entry, and a field on the readonly struct would make a write through one copy invisible through another. Deriving from Dictionary (rather than wrapping it) also keeps the struct exactly one reference wide — no extra allocation, no widened value — and leaves every existing Dictionary interop path (the implicit conversions, the ICollection<T> casts, the reflection bridge’s backing-field probe) binding as before. Second, map<K,V> is golib’s hottest type, so the nil test is !typeof(TKey).IsValueType && (object?)key is null: typeof(TKey).IsValueType is a JIT-time constant, so for a value-type key — map[string]V, map[int]V, the overwhelmingly common shape — the test and every branch it guards fold away and those instantiations compile to exactly the code they had before nil keys existed. Only a reference-typed key pays a null check, and the slot operations themselves sit behind [MethodImpl(MethodImplOptions.NoInlining)] so the hot members stay small. Measured: PerfMap (map[int]int) is flat — 276.4 ms with the slot vs 271.8 ms without (median of three 9-run sessions each), inside the 260–310 ms run-to-run band the unchanged build spans on the same machine.

One consumer cannot see the slot and had to be threaded explicitly: reflect.DeepEqual walks the backing IDictionary through a reflected field probe, which never yields a nil key, and a lone nil entry does not necessarily show up in the Len comparison either (one extra ordinary key on the other side hides it). IMap therefore exposes a non-generic NilKeyEntry(present, boxed value) — with a default implementation on IMap<TKey, TValue> that asks the comma-ok indexer, so the generated named-map wrappers satisfy it with no go2cs-gen change; DeepEqual compares that entry before the dictionary walk. (Guarded by NilMapKey: set/get/comma-ok/overwrite/delete/len/range/ clear on map[any]string, a nil-key composite literal, map[error]int, and the nil-key reads on both a nil and an empty map, all output-compared vs go run. Before the fix the very first m[nil] = … threw ArgumentNullException, which is how sync’s TestIssue40999 died as an infrastructure error.)

PRINTING a nil-key map is a second such consumer, one layer down. fmt orders map keys through internal/fmtsort, which walks the map with reflect.Value.MapRange and compares the key Values — and the reflection bridge typed each entry from its BOXED OBJECT, which for the nil key is null, so Key() handed back the invalid zero Value. fmtsort.compare cannot compare that at all: it reads aVal.Type() first thing and falls through to panic("bad type in compare: " + aType.String()) on a nil type, so printing any map carrying a nil key died inside fmt. Go’s rule is the slot rule the bridge already applies to struct fields and slice elements — a map entry Value is typed by the map’s DECLARED key/value type, so map[any]V hands out Kind Interface keys whatever the dynamic value is, and a nil key or value is a VALID nil Value of that type. MapIter now carries the map’s key and value types (plus the map Value’s read-only bits) and Key()/Value() build through makeTypedValue, which also makes fmtsort’s nil-compares-low rule reachable for the first time — a nil key sorts first, exactly as Go prints it. (Guarded two ways: NilMapKey gains the printing shapes — map[any]int with a nil key through both Println and %v, a nil-only map, map[error]int, map[*int]int — and the new ReflectMapRangeNilKey drives the bridge directly over map[any]int, a named map[any]int, map[*int]string, map[error]int, a concrete map[string]int, a slice-valued map and a nil interface VALUE, printing only order-independent facts because Go’s map iteration order is unspecified. Pre-fix it reports any: 0 0 1 5 3 — zero interface-kind keys, zero nil keys, ONE invalid key, and a value sum of 5 instead of 6 because the nil entry was skipped — against Go’s any: 3 1 0 6 3.)

An INTERFACE map key compares by Go equality, never by adapter identity

Go compares interface values by (dynamic type, dynamic value), and that one relation serves both == and map-key lookup: a map[Iface]V finds an entry under exactly the values == calls equal. In the conversion the two had diverged. Emitted ==/!= route through golib’s builtin.AreEqual, which unwraps the three generated adapter tiers (IInterfaceAdapter, IжAdapter, IValueAdapter) before comparing — but map<K,V>’s backing Dictionary<TKey,TValue> used the default comparer and compared the wrappers.

That gap is observable because an interface value’s wrapper is not stable. The same Go dynamic value is presented through whichever adapter the static interface currently holding it calls for, so asserting an Object to a narrower dependency yields a different wrapper object over the same receiver box. Under the default comparer the asserted value could no longer find its own entry in the map it came out of — while == on the very same pair still answered true, because AreEqual unwrapped. Equal but unfindable:

M := make(map[dependency]*graphNode)
for obj := range objMap {                       // objMap is map[Object]*declInfo
    if obj, _ := obj.(dependency); obj != nil {
        M[obj] = &graphNode{obj: obj}
    }
}
for obj, n := range M {
    for d := range objMap[obj].deps { /* ... */ }   // every key of M IS a key of objMap
}
foreach (var (obj, n) in M) {
    foreach (var (d, _) in (~objMap[obj]).deps) { /* ... */ }
}

This is go/types’ own initorder.dependencyGraph, and it is why the converted type checker could not type-check any source: the missed lookup returned a nil ж<declInfo>, and ~ on it nil-panicked one frame later — surfacing through check.cs:430, handleBailout’s faithful re-panic of Go’s own default: panic(p) arm, which is a bailout frame and not the fault site.

The fix is in golib and centralizes on the relation that already existed: GoEqualityComparer projects AreEqual as an IEqualityComparer<TKey>, hashing the unwrapped root so the hash stays consistent with it — the same rule the compile-time ImplementGenerator adapters already applied (m_box.GetHashCode()), which the runtime shells never had. It is installed only for key types that can actually carry an adapter (typeof(TKey).IsInterface, or any); a concrete key — @string, an integer, a converted struct — is never wrapped and keeps EqualityComparer<TKey>.Default’s devirtualized path, the test being a JIT-time constant per instantiation. Restating the relation inside each generated shell was rejected for the reason the defect illustrates: AreEqual is golib’s single definition of Go equality, and a second copy per shell class is exactly the drift that produced this. (Guarded by the InterfaceAssertionMapKey behavioral test — pointer- and value-receiver implementors, an Object that is not a dependency, lookup after narrowing, lookup after re-widening, and the Object(d) != obj identity probe that pinned the equal-but-unfindable split.)

Named map types and constrained map access

A defined map type — type Grades map[string]int — emits the [GoType("map[K, V]")] partial struct forward declaration (completing the long-standing visitMapType stub), implemented by go2cs-gen’s Map template: full forwarding of IMap<K, V> (including the two-value comma-ok indexer), IDictionary<K, V>, enumeration, and the ISupportMake factory through the wrapped map<K, V>. Its composite literal wraps the concrete map literal in the named constructor — new Grades(new map<@string, nint>{["a"u8] = 1}) — mirroring named arrays/slices (a direct indexer-initializer would target a default wrapper with no backing dictionary; the old emission produced Go-style key: value inside C# braces — CS1513). Comma-ok indexing works through a constrained map type parameter too: v, ok := m[k] where M ~map[K]V detects the map CORE of the constraint (both at the assignment’s tuple gate and in the index emission) and routes the same m[k, ꟷ] two-value indexer, which lives on IMap<K, V> itself. The nil comparison m == nil — Go’s only legal map comparison, maps.Clone’s nil-preserve guard — emits the IMap.IsNil property (if (m.IsNil); backing-store null, distinct from an allocated empty map — no operator exists on a type parameter, CS8761), and delete(m, k) on a constrained map binds a golib delete(IMap<K, V>, K) overload (key/value types infer from the interface conversion). (Guarded by the GenericTypeInference extension EqualMaps — a maps.Equal clone over a named map type through the constraint, comma-ok + comparable-erased equality, values vs Go.) For source-generated named-map wrappers, the generator parses the [GoType("map[K, V]")] payload at the top-level comma, not every comma in the string. This matters for function-valued maps: type opTable map[CrossPkgLib.Ticks]func(int, int) int emits map<global::go.CrossPkgLib_package.Ticks, Func<nint, nint, nint>>, preserving the full delegate as the value type. Any source-file alias used inside the [GoType] payload is resolved through Roslyn and rewritten to its fully-qualified target before the template emits IMap<K, V>, IDictionary<K, V>, and ICollection<KeyValuePair<K, V>>; generated files therefore do not depend on file-local package aliases such as using token = .... (Guarded by NamedMapCrossPkgKey.)

The named arm must also carry the map type into the key/value slot emission, exactly as the unnamed arm does. Every MapSource slot rule in convKeyValueExpr is gated on that type: a pointer KEY or VALUE boxes to Ꮡx rather than aliasing a deref’d value into a ж<T> slot (CS0029), an any key or value slot re-renders a string literal as (@string)"…"u8 instead of the bare u8 span (which has no conversion to an object slot — CS1503), an untyped-constant any key boxes at Go’s default type so the store and every lookup agree on the boxed type, and an array-typed key clones into the map. Left nil, a named map type silently opted out of all of them, so type namedAny map[any]int with namedAny{nil: 1, "b": 2} emitted ["b"u8] = 2 and failed to compile while the identical unnamed map[any]int{nil: 1, "b": 2} one line above emitted [(@string)"b"u8] = 2. (Guarded by the DeepEqual behavioral test’s named-map block, which is written over map[any]int precisely so the any-slot rule is exercised through a named type.)

A bare make(Grades) with no size argument defaults the size to 0 — emitting new Grades(0) — so the wrapper’s allocating (nint size) constructor runs and the backing dictionary is created. The generated wrapper struct has that (nint size) constructor but no parameterless one, so a plain new Grades() would be default(Grades) — a nil map (null backing store, so m == nil is true and a write panics), whereas Go’s make returns a non-nil empty map (m == nil false, writes succeed). The default is applied only to *types.Named defined types: the unnamed map<K, V> builtin already allocates in its own parameterless constructor and stays new map<K, V>(), and a type alias (type M = map[int]int) resolves to that builtin rather than a wrapper — so neither drifts (make emission in convCallExpr.go, right beside the named-channel default below). This mirrors the named-channel unbuffered default (make(closeWaiter)new closeWaiter(1)); a sized make(Grades, n) (already new Grades(n)) and the Grades{} composite literal are non-nil already. (Guarded by NamedMapMakeNonNilmake with and without a size, a plain nil var, and a composite literal, each == nil-compared and output-compared vs Go.)

Two [GoType] payload conventions coexist, and the generator’s alias substitution must tell them apart. The map/channel emitters write dotted types in source-alias form (CrossPkgLib.Ticks, via getAliasQualifiedTypeName), which the substitution above resolves; the slice/array element and defined-over-selector emitters write the namespace-qualified form (io.fs_package.FileInfo, via getFullyQualifiedTypeName), which roots through the go namespace and must pass through untouched. The telltale is the segment after the leading identifier: a real alias maps to a package class, so its next segment is a type name — a _package-suffixed next segment means the leading identifier is a namespace segment that merely collides with a file alias. net/http’s fs.go aliases io while declaring type fileInfoDirs []fs.FileInfo[]io.fs_package.FileInfo; substituting the io. produced the nonexistent go.io_package.fs_package.FileInfo (CS0426 ×48). The substitution skips exactly those occurrences (a negative lookahead on _package.). On the converter side, the namespace-qualified form must lead with the canonical qualifier, never a file-local Δ collision-rename: a consumer whose own namespace has a same-named child imports under using ΔIoLike = IoLike_package;, but []ΔIoLike.FsLike_package.Info resolves nowhere in the alias-free .g.cscanonicalizeQualifierRename reverts a leading import-rename segment (mirroring the visitTypeSpec global-using-target rule). (Guarded by NamedSliceChildPkg — a nested-namespace consumer package importing both IoLike and IoLike/FsLike, with a named slice of the subpackage’s type used across the assembly boundary.)

A map indexed by a non-empty interface key converts a concrete key expression through the same interface-adapter path used by assignments and call arguments. For example, seen[item] = "kept" where seen is map[Node]string and item is *Item emits seen[new ItemжNode(item)] = "kept"u8; the comma-ok read emits the same adapter for the key, seen[new ItemжNode(item), ꟷ]. This records the pointer implementation (GoImplement<Item, Node>(Pointer = true)) and keeps dictionary lookup semantics aligned with Go’s interface key identity. Empty-interface map keys keep their existing literal handling (map[any]... turns string literals into Go strings rather than UTF-8 spans), and pointer-typed map keys keep the direct pointer-box path. (Guarded by InterfaceMapKeyPointer.)

A pointer-keyed map indexed by the method’s receiver supplies the receiver’s box as the key, exactly like the deref-aliased pointer-parameter case: t.m[c] inside func (c *conn) … emits t.m[Ꮡc] (plain read, write, and the comma-ok t.m[Ꮡc, ꟷ] alike) — net/http transport.go’s idle-connection bookkeeping (t.idleLRU.m[pc]) passed the deref-aliased VALUE where ж<persistConn> was expected (CS1503 plus the (v, ok) deconstruction cascade). The box exists only on a direct-ж method, so the receiver-as-map-key body shape itself now promotes the method to direct-ж (bodyUsesReceiverAsPointerValue gained an IndexExpr case, gated on a pointer-KEYED map operand) — a method whose only pointer-use of its receiver is the map key still gets this ж<conn> Ꮡc. A pointer LOCAL is unchanged (it is the key — no ), and delete(t.m, c) boxes through the ordinary pointer-argument rule once the method is direct-ж. (Guarded by PtrKeyMapReceiverLookup — pure-shape promotion, plain read/write, comma-ok, and delete through two distinct receiver identities, values vs Go.)

A value sent into a channel of non-empty interface element type converts through the same interface-adapter path used by assignments and call arguments — the send emission previously tested the CHANNEL type itself for interface-ness (never true, its underlying is *types.Chan), so no conversion ever fired. A value implementation sends bare while recording the implement pair for the generator (vs.ᐸꟷ(new dog(name: "rex"u8)) with [assembly: GoImplement<dog, speaker>]); a pointer implementation wraps the box in its generated pointer adapter:

ps := make(chan speaker, 1)
c := &cat{name: "tom"}
ps <- c
var ps = new channel<speaker>(1);
var c = (new cat(name: "tom"u8));
ps.ᐸꟷ(new catжspeaker(c));   // records GoImplement<cat, speaker>(Pointer = true)

A pointer-typed send value renders as its box (parity with the argument-position rule in convExprList), and a type-parameter element (chan T in generic code) keeps the bare emission. The string-literal empty-interface arm of the same helper is described under Empty Interface (any). (Guarded by AnyStringLitChanSend — a value impl and a pointer impl sent through a chan speaker, method-dispatched on receive, output-compared vs Go.)

Named channel types

A defined channel type — type closeWaiter chan struct{} (net/http’s h2 bundle) — emits the [GoType("chan T")] partial struct forward declaration (completing the long-standing visitChanType stub; the whole corpus previously had NO GoType("chan …") — CS0246 at every use), implemented by go2cs-gen’s Channel template: the wrapper holds a channel<T> and forwards its full surface — the Go-visual send/receive members (ᐸꟷ, ꟷᐳ, including the select-registration ᐸꟷ(v, ꓸꓸꓸ)/Sending/Receiving forms), the comma-ok Receive(ꟷ)/Received pair, IChannel’s object-typed members, enumeration for range, the ISupportMake factory, and a (nint size) constructor so make(closeWaiter) emits new closeWaiter(0) — a REAL unbuffered channel (the make path resolves the chan through Underlying(), giving named channels the same unbuffered default as plain chan T; the wrapper constructor forwards the size unclamped, so named channels can be unbuffered — see the channel-runtime section below):

type closeWaiter chan struct{}
func (cw *closeWaiter) Init() { *cw = make(closeWaiter) }
func (cw closeWaiter) Close() { close(cw) }
func (cw closeWaiter) Wait()  { <-cw }
[GoType("chan EmptyStruct")] partial struct closeWaiter;

[GoRecv] internal static void Init(this ref closeWaiter cw) {
    cw = new closeWaiter(0);
}

internal static void Close(this closeWaiter cw) {
    close<EmptyStruct>(cw);
}

internal static void Wait(this closeWaiter cw) {
    ᐸꟷ<EmptyStruct>(cw);
}

Two deliberate wrinkles. Free-function channel ops name the element type explicitlyᐸꟷ<EmptyStruct>(cw), close<EmptyStruct>(cw): golib’s ᐸꟷ<T>(channel<T>)/close<T>(in channel<T>) reach the wrapper only through its user-defined conversion to channel<T>, which C# generic inference never considers (CS0411); the explicit type argument lets the conversion apply at the argument instead (namedChanElemTypeArg, applied at the unary-receive, select-registration and close emission sites — a plain chan T operand is byte-identical; a package that ALSO declares a close method keeps the builtin. shadow qualification of the general builtin path, so net/http emits builtin.close<EmptyStruct>(cw)). The wrapper’s Close is an explicit IChannel implementation only: Go code commonly defines its OWN Close() method on a named channel type (the closeWaiter shape above), and a public instance Close would shadow that method’s extension form at every call site; close(ch) routes through the golib free function, so no public surface is lost. (Guarded by NamedChannelType — the closeWaiter trio plus a buffered type intQueue chan int exercising make/send/len/cap/receive/comma-ok/close/range/select, output vs Go.)

A function-LOCAL named type declaration hoists to member level (slice/map/channel/array/pointer)

C# forbids a type declaration inside a method body, so a type X []T / type X map[K]V / type X chan T / type X [N]T declared inside a function cannot emit its [GoType(…)] partial struct X; forward declaration in place — the following statements would then parse as MEMBER declarations (CS1519 Invalid token 'foreach' in a member declaration, CS1513 } expected, the map form’s CS8124). A local type X struct{…} already hoists: visitStructType/visitIdent/ visitInterfaceType each redirect the declaration into currentFuncPrefix (emitted at member level ahead of the method), rename it with the enclosing-function prefix (ExampleChunk_People), and register the lifted name in liftedTypeMap so every reference resolves to it. The array/slice, map, and channel emitters did not — they wrote the forward declaration straight into the method body (the reported slices example_test/maps maps_test defect). The shared helper liftLocalTypeDecl (visitTypeSpec.go) now applies that same hoist to all three: at package scope it is a no-op (target stays v.targetFile, finish() does nothing, so production emission is byte-identical), and inside a function it prefixes the name, registers the lift, redirects to a member-level builder, and flushes into currentFuncPrefix. A local slice/array of a local element type also needs the element resolved to its lifted name: visitArrayType’s simple-identifier fast path (which keeps the written name so [3]rune stays rune) is skipped when the element is itself a lifted local type (!v.liftedTypeExists), routing it through getFullyQualifiedTypeName, which resolves liftedTypeMap — so type People []Person (Person a local struct) emits [GoType("[]ExampleChunk_Person")] partial struct ExampleChunk_People;, not the raw []Person. (Guarded by the LocalNamedTypeDecls behavioral test — a function-local named slice-of-local-struct, map, channel, and fixed-size array, each constructed/ranged/indexed in the body and output-compared vs Go; the unfixed converter leaks four partial struct …; declarations into the method body.)

Two completions of the same rule, both demonstrated by encoding/gob’s test suite:

Known residual: a conversion expression to a hoisted local named pointer type (NodePtr(&Node{V: 9}), with type NodePtr *Node declared in the function) still emits the pre-hoist source name (new NodePtr(…), CS0246). The composite kinds do not have this — a local Tally(m) correctly renders ((main_Tally)m) — so the gap is specific to the named-pointer conversion arm’s target-name resolution. It was previously masked by the hard syntax error above and has no consumer among the measured packages (gob only declares Rec and takes its address); the LocalNamedTypeDecls guard therefore uses the assignment form var np NodePtr = &Node{V: 9}.

An embedded field’s NAME is the UNQUALIFIED type name (dot-imported embeds)

An embedded struct field’s name is, per the Go spec, the unqualified type name. A cross-package embed written as a selector (struct{ io.Writer }) already stripped its qualifier for the field name; a dot-imported embed (import . "io" then embedded ReaderFrom) reaches the emitter as a bare *ast.Ident, yet getAliasQualifiedTypeName still renders it package-qualified — and, once the package is a collision-rename, as Δio.ReaderFrom. Gating the qualifier-strip on the selector form left that qualifier in the field name (internal io_package.ReaderFrom Δio.ReaderFrom;), whose embedded dot is a C# syntax error (CS1003 '(' expected / CS1026 ') expected' — the reported io io_test defect). visitStructType now strips to the last segment whenever the resolved embedded-type name carries a qualifier (covering both the selector and dot-imported-ident forms; a same-package embed has no dot, so it is a byte-identical no-op), yielding the correct public io_package.ReaderFrom ReaderFrom;. (This is one root among several in the io test suite, which remains blocked by separate import . "io" using-alias resolution issues — the Δio namespace is emitted but never aliased.)

Select statement lowering (terminating and empty clauses)

A select lowers to a C# switch over a golib runtime call that commits exactly ONE case and returns its ordinal: the blocking form switch (select(ᐸꟷ(a, ꓸꓸꓸ), …)) (selectgo — commits a uniformly-random ready case or parks), and the default form switch (trySelect(…)) (the same poll pass, returning -1 so the C# default: label runs when no case is ready). Receive cases keep a case N when selᴛN.ꟷᐳ(out v): guard that consumes the committed value; send cases are performed by the runtime commit and get a bare case N: label. Every case’s operands are hoisted into select-scoped temps (var selᴛN = …;) emitted in strict source order and evaluated exactly once at select entry — a receive case’s channel operand (used by BOTH the registration and the guard) and a send case’s whole registration call, so the registration list names only temps (see the operand-evaluation section below). The registration calls (ᐸꟷ(ch, ꓸꓸꓸ) receive, ch.ᐸꟷ(v, ꓸꓸꓸ) send) return SelectOp case descriptors and select(params SelectOp[]) runs a faithful selectgo (see the channel-runtime section below): it commits exactly ONE ready case — chosen uniformly at random — or parks until one becomes ready. A committed receive’s value crosses to the winning case’s unchanged guard (case N when ch.ꟷᐳ(out v):) through a per-thread pending-frame stack the guard pops (a stack, so a select nested in the guard’s target expression cannot destroy the outer commit — see the channel-runtime section), so the emitted select text is identical to the pre-redesign form. Two structural completions (io pipe.go’s read):

The golib non-blocking receive underpinning the default-form guards distinguishes the two “no value” cases per Go semantics: a closed empty channel is receive-ready with the zero value; an open empty channel reports not-ready, so the default: is taken. (Guarded by the SelectStatement extensions firstMsg — terminal blocking select in a value-returning func — and poll — empty default: after a returning case, polled both before and after close.)

A NIL channel is never ready — and asking must not throw

golib models a channel as a struct, so the nil channel is that struct’s ZERO value: every field is null. Go gives a nil channel well-defined behavior — it is never closed, a receive or send on it blocks forever, and in a select with a default the nil case is simply not chosen — so the readiness probes must report “not ready” rather than dereference the absent state. Most of them already did (SendIsReady / ReceiveIsReady / Receiving all null-check their backing fields); IsClosed did not, so merely asking whether a nil channel was closed threw a NullReferenceException.

This is not an exotic shape. os/exec’s Start runs

if c.ctx != nil {
	select {
	case <-c.ctx.Done():
		return c.ctx.Err()
	default:
	}
}

and context.Background().Done() is a nil channel, so every child process launched through a background context crashed in the probe — the last blocker on math/rand’s TestDefaultRace. IsClosed now reports false for a nil channel, which makes the non-blocking receive fall through to “not ready” and the default: clause run, matching Go. (Guarded by NilChannelSelectDefault: nil receive and comma-ok receive taking the default, len/cap of a nil channel, a real channel behaving normally alongside, and a mixed select where the nil case must never win over a ready real case.)

The default form routes through trySelect — send cases are unguarded in both forms

The default: form was originally lowered as switch (ᐧ) with per-case try-operation guards (case ᐧ when ch.ꟷᐳ(out v): / case ᐧ when ch.ᐸꟷ(v, ꟷ): — the interim fix for the dropped-send defect os/signal’s process exposed, where an unguarded case ᐧ: ran unconditionally and silently dropped the value). That shape is single-fire by construction (C# evaluates the ordered guards until the first true) but its ready-case choice is the case order, never uniform-random — provably unfixable against an ordered C# switch. The default form now routes through golib’s non-blocking trySelect(…): the same registrations as the blocking form, the same selectgo poll pass (distinct cores locked in Id order, Fisher-Yates pollorder, exactly one commit under the held locks), no parking, and -1 — the default sentinel matched by the C# default: label — when no case is ready:

select {
case c <- sig:
default:   // send but do not block for it
}
switch (trySelect(c.ᐸꟷ(sig, ꓸꓸꓸ))) {
case 0: {
    break;
}
default: {
    break;
}}

Send cases get a bare case N: label in BOTH forms — the runtime call performed the winning send itself, so a guard would either send the value a second time or fail and silently skip the chosen clause body. Receive cases keep their case N when ch.ꟷᐳ(out v): guard, which consumes the committed value from the runtime’s per-thread pending-frame stack. Go’s remaining rules live in the runtime: a closed channel’s send case panics (Go panics even when a default: exists — the poll pass checks closed before readiness, so a closed FULL channel panics rather than taking the default), and a nil channel’s case is never ready, so it is never chosen. golib’s non-blocking Sent/ᐸꟷ(v, ꟷ)/TrySend surface remains for direct non-blocking sends (and the -uco=false named-method mode), delegating to the same single runtime send implementation.

A pointer-element channel forced two further root fixes, both pre-existing and both previously unreachable because the dropped send never compiled the value expression. net/rpc’s func (call *Call) done() sends call.Done <- call: (1) the capture-mode pre-pass had no send-value position, so the method was never promoted to direct-ж and had no receiver box to hand out — bodyUsesReceiverAsPointerValue now recognizes a SendStmt whose value is the pointer receiver; and (2) convSendValueExpr applied the pointer ident context only for interface elements, so a deref-aliased pointer (a pointer parameter, or the receiver) rendered as its value and could not bind the in ж<T> send parameter (CS1503). Both forms of send route through convSendValueExpr, so the statement form ch <- recv — broken in exactly the same way — is fixed by the same change.

(Guarded by SelectSendDefault: full buffered taking the default then the same select succeeding once drained, free-capacity buffered delivering the value, unbuffered with a waiting receiver, nil, closed-panics-through-the-default, one-ready-among-several, a send and a receive case with neither ready, exactly-one-send when several are ready, and a no-default select still blocking.)

Every case’s operands are hoisted — evaluated exactly once, in SOURCE ORDER, at select entry

Go’s spec evaluates, for every case in the statement, a receive operation’s channel operand and a send statement’s channel AND right-hand-side expressions exactly once, in source order, upon entering the select. Leaving an operand inline in the select(…)/trySelect(…) registration argument list breaks that in two distinct ways.

Evaluated twice. A receive case’s operand appears in the registration call AND again as the winning guard’s receiver, and C# reads a struct method call’s receiver AFTER evaluating its arguments, so even a bare identifier can change under the guard (the out-target expression runs first). A non-referentially-stable operand — case <-time.After(d): (net/http/pprof), case <-fresh():, or an identifier the out-target reassigns — re-evaluates to a DIFFERENT channel: the runtime’s pending-frame core match then (correctly) refuses delivery, and the factory’s side effect runs twice.

Evaluated out of order. C# evaluates the registration arguments in argument order, i.e. AFTER every hoisted temp. A send case left inline therefore had its channel operand and value expression observed after a later receive case’s operand: a select whose FIRST case was a send observed [recv-chan, send-chan, send-val] where Go’s order is [send-chan, send-val, recv-chan].

The converter therefore hoists EVERY case’s operands into select-scoped temps, emitted in strict source order, leaving the registration list naming only temps. Uniformly, with no stability analysis — channel<T> struct copies share one core, so the temp preserves identity, and the hoist IS Go’s up-front-once evaluation model:

select {
case v := <-fresh():
    ...
}
var sel1 = fresh();
switch (select(ᐸꟷ(sel1, ꓸꓸꓸ))) {
case 0 when sel1.ꟷᐳ(out var v): {
    ...
    break;
}}

A SEND case hoists its whole registration call rather than two separate operand temps (SelectSendRecvMix, a send case textually first on a full channel plus a receive case on the same channel):

select {
case ch <- 8:
    fmt.Println("send fired on full channel (wrong)")
case took = <-ch:
}
var sel3 = ch.ᐸꟷ(8, ꓸꓸꓸ);
var sel4 = ch;
switch (select(sel3, ᐸꟷ(sel4, ꓸꓸꓸ))) {
case 0: {
    fmt.Println("send fired on full channel (wrong)");
    break;
}
case 1 when sel4.ꟷᐳ(out took): {
    break;
}}

That is both legal and stronger than two operand temps. Sending/ᐸꟷ(v, ꟷ) only BUILDS a SelectOp descriptor — golib’s Sending is return new SelectOp(m_core, isSend: true, sendValue: value); — and the communication is performed later by the runtime commit inside select/trySelect, so moving the call ahead of the switch moves no send. The call evaluates its receiver then its argument, i.e. channel operand then value expression, contiguously and in source order: exactly Go’s rule. And the value expression keeps its ORIGINAL argument position, so every implicit conversion the in T parameter applies — untyped-constant narrowing to the element type, interface-adapter wrap, @string/nint boxing, array clone (see the send-value rules above) — is preserved by construction, with no new type inference anywhere. A separate value temp would have to re-render the element type to declare itself, and var inference is provably wrong there: case bch <- 200: on a chan byte becomes var t = 200; — an int, which no longer converts to byte at the call (CS1503) — and any divergence in a hand-rendered element type would SILENTLY change the conversion instead. The whole-call hoist also leaves the ж<T>-pointer element case (net/rpc’s call.Done <- call) unaffected by construction.

A send case’s winning label stays a bare case N: — the runtime commit performed the send, so it carries no guard and nothing re-evaluates.

(Guarded by SelectOperandOnceEval for the once-only property — ready and parked call-expression operands with printed call counters, the reassigned-identifier out-target, and the default form; counter-proven against the pre-fix emission, which FailFasts on the pending-frame core-match assert. And by SelectOperandSourceOrder for the ordering property — a send case textually first with all three operand expressions logging their fixed source positions: the blocking form with the receive winning and with the send winning, a default-form select interleaving send/receive/send with nothing ready (including an untyped 200 into a chan byte and a value boxed into a chan any), and the already-correct receive-first direction as a regression anchor. Every select there is deterministic by construction — exactly one case can ever be ready — so the uniform-random commit never affects the output. Counter-proven against the pre-fix converter, which prints 3:recv-chan 1:send-chan 2:send-val where Go prints 1:send-chan 2:send-val 3:recv-chan.)

Known exposure: marker-shaped USER identifiers can collide with synthetic names

The converter’s synthetic-name markers — (TempVarMarker, U+1D1B: selᴛ1, tupleᴛ2, elemᴛ0, iᴛ1, initᴛ<name>, lifted-type <name>ᴛ1), ʗ (CapturedVarMarker), Δ (ShadowVarMarker), and the rest of the Symbols.cs family — are exotic Unicode LETTERS, legal in Go identifiers. A Go program that itself declares an identifier matching a generated shape (e.g. selᴛ1 used in a select, emitting var selᴛ1 = selᴛ1;) collides with the synthetic name — loudly, at C# compile time (CS0128/CS0102), never silently. A general Δ-rename of user identifiers matching the numbered-temp shape was attempted at the sanitizer choke point (getCoreSanitizedIdentifier) and REJECTED: that choke point also renders the converter’s own synthetic names (loop temps iᴛ1, lifted anonymous/named-value types main_MyBoolᴛ1, cross-file anon-struct names), so the blanket rule Δ-renamed synthetic names too and churned non-select goldens; distinguishing user from synthetic identifiers requires threading origin through many naming call sites — deliberate sprawl for a trigger that demands typing U+1D1B in Go source. Accepted as a documented family-wide exposure: the failure mode is a compile error naming the colliding identifier, and the workaround is renaming the pathological identifier in the Go source.

Real channel runtime — the hchan/selectgo port (rendezvous, cap/len, single-fire, uniform-random)

The four long-standing channel-semantics gaps (no unbuffered rendezvous; make(chan T) conflated with make(chan T, 1); a blocking select performing EVERY send case; first-match instead of uniform-random ready choice) were closed together by rewriting golib channel<T> over a faithful port of Go’s runtime machinery (docs/phase4/DESIGN-channels.md — the blessed synthesized design; rendezvous and the select rework land as ONE unit because staging rendezvous first regresses the legacy Sending path):

(Guarded by ChannelRendezvous — cap/len 0, not-ready probes with no counterpart, rendezvous round-trip, ping-pong alternation; ChannelCapLen — buffered fill/wrap/drain, nil/unbuffered len/cap, comma-ok drain-after-close; SelectSingleFire — exactly one delivery among multiple ready send cases, 100-iteration volume guard; SelectSendRecvMix — send+recv cases on the same channel, one-commit-per-select; SelectRandomFairness — both branches of a two-ready select taken over 200 iterations; CloseWakesBlocked — close waking parked receivers/senders/selects in both directions plus the whole panic family; NilChannelInSelect — nil cases never ready beside live ready and parked cases; plus the extended NamedChannelType unbuffered named-channel rendezvous and the pre-existing select/channel suite.)

An escaping comm-clause binding receives into a temp and heap-boxes at clause entry

A case result := <-ch: whose bound variable’s address is taken in the clause body — internal/fuzz coordinatorLoop’s c.crashMinimizing = &result and writeToCorpus(&result.entry, …) — escapes to the heap, so the body’s address-of emission references the Ꮡresult box companion (an escaping := local’s form). The comm-clause label emitted only a plain out var result, never a box, leaving Ꮡresult undeclared (CS0103 ×2, the last own-errors keeping internal.fuzz red after its CS0234s cleared). The when guard’s out var slot cannot declare a ref local, so selectCommBinding (visitSelectStmt.go) receives into a uniquely-numbered temp and opens the clause body with the entry-time box pattern proven by the escaping-parameter preamble:

case 2 when (~c).resultC.ꟷᐳ(out var result1): {
    ref var result = ref heap(result1, out var result);

The gate is identHasHeapBox — the exact predicate the body’s &name emission uses — so the box is declared iff it is referenced; alias/box names mirror convertToHeapTypeDecl (sanitized analyzed name for the value alias, raw analyzed name behind for the box, matching boxBaseName). Both bindings of the (val, ok) form are checked. A non-escaping binding keeps the direct out var <name> form (preserving the shadow-rename render, e.g. out var errΔ5), and an ASSIGN-mode rebind of an existing boxed local already writes through its ref alias — the full-stdlib A/B footprint was exactly internal/fuzz/fuzz.cs. (Guarded by SelectEscapeBinding — escaping binding written through both directions, escaping (val, ok) binding with a field address through the box, and a mixed escaping/plain select, output-compared vs Go; the pre-fix converter fails it with exactly the CS0103 Ꮡres class. A clause taking ONLY a field address (&res.value, no whole-var &res) still copy-boxes — the known assignment-position escape-analysis gap, out of scope here.)

Generic Constraints

A Go generic constraint becomes a C# where clause. Most type-set constraints lift to the matching golib/.NET interface — a []T element constraint to ISlice<T>, [N]E array-core to IArray<E>, map[K]V to IMap<K,V>, chan T to IChannel<T> — plus, for operator-bearing type sets, the System.Numerics operator interfaces (IAdditionOperators, IComparisonOperators, …) so the body’s +/</== on the type parameter compile. The Go built-in comparable maps to golib’s CRTP comparable<T>.

An array-core constraint ~[N]E lifts to IArray<E>

A type-set constraint whose core is an ARRAY — func polyAdd[T ~[256]fieldElement](a, b T) T (ML-KEM’s ringElement/nttElement share the core [256]fieldElement) — must map to where T : IArray<E>, NOT to the operator interfaces the general type-set path would produce. An array is a comparable type in Go, so the operator-set resolver put Array in the comparable set and lifted IEqualityOperators<T, T, bool>; the named-array [GoType] wrapper (which the converter emits for ringElement etc.) does not implement that interface, so every instantiation failed CS0315, and the interface exposes no array surface, so the body’s t[i] (CS0021), for i := range t (CS8130 on the index deconstruction), and for _, x := range t (CS1579/CS8183) had nothing to bind against. The fix (getArrayConstraintElem in constraintOperations.go, a new branch in getGenericDefinition) detects a single-array-core type set, extracts the element type, and emits where T : /* ~[N]E */ IArray<E>, new(). The array wrapper already declares IArray<E>, ISupportMake<wrapper> (the go2cs-gen Array inherited-type template), whose ref E this[nint] indexer and IEnumerable<(nint, E)> enumeration supply exactly the indexing/ranging surface the body needs, and the new() (appended by the same path) covers var f T/T{} construction. Greened crypto/internal/mlkem768 (census 254 → 255); the reconvert A/B changed only mlkem768.cs’s four constraint lines. (Guarded by GenericArrayConstraint — two array-wrapper types over a shared ~[4]fieldElement core through a generic function that indexes, index-ranges, value-ranges, and constructs the type parameter, values vs Go.)

A single-term pointer constraint [P *T] erases the parameter to ж<T>

A type parameter constrained to a single, non-tilde pointer term — go/types’ flat-copy helper func clone[P *T, T any](p P) P { c := *p; return &c } (predicates.go) — cannot be modeled as a C# type parameter: no C# constraint fixes a parameter to a specific constructed type, and ж<T> implements no interface through which *p/&c could be expressed generically. The operator-lift fallback emitted where P : /* *T */ IEqualityOperators<P, P, bool>, new() with the deref dropped (c = p — CS0029 P→T) and the box mismatched (return Ꮡc — CS0029 ж→P), and the call site's synthesized `clone<ж<ΔSignature>, ΔSignature>(asig)` failed CS0311 (ж<> implements no IEqualityOperators).ΔSignature>

The Go spec makes the faithful lowering an identity, not an approximation: a non-tilde term’s type set is a singleton, so P’s only permissible type argument is *T itself. The converter therefore erases such a parameter (pointerCoreConstraint in constraintOperations.go): it leaves the emitted <…> list and where clauses (a breadcrumb comment preserves the Go constraint), renders as ж<T> everywhere it appears (a getAliasQualifiedTypeName arm beside the *types.Pointer arm), and the parameter classification treats a p P exactly like a p *T (paramPointerType — deref alias, box naming), so the entire existing pointer machinery applies unchanged:

internal static ж<T> clone<T>(ж<T> p)
    /* where P : *T (erased: P renders as ж<T>) */
{
    ref var p = ref p.Value;

    ref var c = ref heap<T>(out var c);
    c = p;
    return c;
}

Call sites drop the erased position from any synthesized explicit type-argument list (renderedTypeArgs, applied at convCallExpr’s two synthesis blocks and convSelectorExpr’s method-group form): clone(asig) emits clone<ΔSignature>(asig), and a callee whose remaining parameters make C# inference sufficient stays bare (setThrough(Ꮡn, 55)). An EXPLICITLY written Go instantiation equally drops erased positions — full (setThrough[*int, int](…)setThrough<nint>(…)), partial (clone[*thing](…) → bare clone(…), the rest inferring), and the function-VALUE form (fv := clone[*thing, thing]var fv = clone<thing>;) — via explicitTypeArgsAfterErasure in convIndexExpr/convIndexListExpr. A C# consumer calls the emitted method naturally — T sits in a real parameter position, so inference works without spelling the phantom P.

The pointer classification flips at every use shape, not just the deref/address pair: returning the parameter WHOLE yields its box (return areturn Ꮡa;), passing it onward to another erased callee — including self-recursion — supplies the box (cloneChain<T>(clone<T>(Ꮡp), …); the interface-shaped argument arm carves out erased params exactly like instantiated pointers), copying it into a local is a Go pointer copy (q := pvar q = Ꮡp;, writes through q land in the caller’s referent), and a nil comparison takes the box form over the nil-deferring entry alias (if p == nilref var p = ref Ꮡp.DerefOrNull(); … if (Ꮡp == nil) — a nil argument reaches the guard instead of throwing at entry, e.g. orZero[*int, int](nil)). The NAMED constraint-interface spellings — [P PtrOf[T]] and the embedded [P interface{ PtrOf[T] }], where type PtrOf[T any] interface{ *T } — resolve to the identical singleton type set and erase identically. The constraint interface’s own DECLARATION follows the existing constraint-interface convention ([GoType] partial interface PtrOf<T> { /* Type constraints: *T */ }): a pointer term is a type-set term, not an embeddable interface (previously it emitted an interface inheriting the struct ж<T> — CS0527), and a GENERIC constraint interface carries its own <T> list, so the arity-0 <ΔT> marker list and its generated operator machinery are both suppressed for it.

Erasure is deliberately gated to the identity case: function type parameters whose constraint type-set is a single non-tilde pointer term. Declined shapes warn instead of silently mis-emitting — an approximate ~*T admits named pointer types, which emit as [GoType("ж<E>")] wrapper classes (not identity with ж<E>); pointer unions have no single identity; and erasing a generic named type’s parameter would change its emitted arity at every use. None occur anywhere in the converted stdlib (exhaustive GOROOT census: go/types’ clone is the only compiled occurrence of the pattern; see DESIGN-pointer-core-typeparam.md on the fix branch for the full study). (Guarded by PointerCoreConstraints — clone/read/write/round-trip through [P *T] and the swapped-order [T any, P *T], flat-copy independence verified, values vs Go.)

An integer named-numeric wrapper implements the integer operator interfaces

A [GoType num:] wrapper (type stringID uint64) already declared the common numeric operator interfaces so it could serve a cmp.Ordered-shaped constraint (IAddition/ISubtraction/ IMultiply/IDivision/IEquality/IComparison/IIncrement/IDecrementOperators), but the integer-only three — IModulusOperators, IBitwiseOperators, IShiftOperators<T, int, T> — were deliberately left off because their operators (%, &|^~, <<, >>) are kind-gated. That left a named integer type unable to satisfy a converter-emitted ~integer operator constraint: internal/trace’s type dataTable[EI ~uint64, E any] instantiated with type stringID uint64 was CS0315 ×48 on exactly those three interfaces. The NumericTypeTemplate operators already exist (same kind-gate), so InheritedTypeTemplate now also declares the three integer interfaces for an integer underlying (float/complex keep only the common set). IShiftOperators additionally requires operator >>> (unsigned right shift) — added to the integer operator block; Go emits no >>>, but the member is needed to satisfy the interface. Cleared internal/trace’s 48 CS0315 (49→1, the residual being the unrelated ΔLabel CS0542). Guarded by NamedNumericOperatorConstraint (a generic mix[K ~uint64 | ~int32] applying modulus/bitwise/both-shifts on the type parameter, instantiated with a named uint64 and a named int32, values vs Go). Corpus-verified against math/big (Word), archive/tar, and time (Duration).

Lifted shift constraint uses the BCL shape IShiftOperators<T, int, T>

The lifted Integer operator set constrains shifts as IShiftOperators<T, int, T> — the shift count is int, not the type parameter. Every BCL binary integer implements exactly that shape (IShiftOperators<TSelf, int, TSelf>); only C# int itself happens to also satisfy the self-typed form, so the self-typed constraint made every non-int instantiation fail (CS0315 — strconv’s bsearch[S ~[]E, E ~uint16 | ~uint32] on ushort/uint). The shape is also exactly what emitted bodies need: the converter coerces every shift count to int (x << (int)(k)), so a generic body can only ever perform T << int. The generated named-constraint interface template (Integer in go2cs-gen) and its dynamic-conversion placeholder shift operators use the same int-count shape, keeping the two emitters consistent. (Guarded by the GenericTypeInference extensions bsearchLike/halve~uint16 | ~uint32 instantiations with a shift on the type parameter, values vs Go.)

Builtins over constrained slice type parameters

golib’s builtins carry interface-typed overloads so a value held as a constrained type parameter (S ~[]E, boxed to its ISlice<E> constraint) binds directly: copy(ISlice<T1> dst, ISlice<T2> src) (plus an ISlice<byte>/@string form), clear(ISlice<T> s), and two-argument min/max constrained on IComparisonOperators (Go’s cmp.Ordered lifts to operator interfaces; a constrained E has no IComparable<E> conversion). The box wraps the same backing array, so interface writes land in the caller’s storage — copy/clear into an S are true write-throughs (span windows, memmove semantics for overlap). Overload resolution keeps concrete calls on the exact slice<T> overloads (an exact parameter beats a boxing conversion), so nothing outside generic bodies changes. Cleared ~37 of the slices package’s constraint seams. (Guarded by the GenericTypeInference extension CopyClearMinMax — copy into and clear through constrained values, write-through verified by value vs Go.)

S-preserving sub-slice and append. Go’s sub-slice of a named slice type yields the same named type sharing the same backing — pdqsort’s recursion depends on it (pdqsort(s[:mid]) with s S). The ISliceWrap<TSelf, T> static-abstract factory (TSelf Wrap(in slice<T> source)) supplies the non-copying reconstruction: slice<T> implements it as identity, every generated named-slice wrapper wraps the window in its own type, and the ~[]E where-clause carries it (ISlice<E>, ISupportMake<S>, ISliceWrap<S, E>). A sub-slice of a constrained type parameter emits golib’s subslice<S, E>(s, lo, hi) (type arguments explicit — E is constraint-only) which routes S.Wrap(new slice<E>(s.Slice(…))); the new slice<T>(ISlice<T> view) constructor SHARES storage (unboxes a slice<T>, reconstructs any other implementer from its source array and window). append on a constrained value binds golib’s append<S, T>(S, params ReadOnlySpan<T>) (S from the first argument, T from the span — fully inferrable) and wraps the result back to S; its body routes to the core slice<T>.Append directly, since a recursive append(…) call would resolve back to itself (slice<T> satisfies the constraints). The same change fixed the named-slice WRAPPER template’s sub-slice members, which routed through ToSpan()detached copies, a silent write-through divergence for named slice types generally; they now route through the wrapped m_value (sharing). (Guarded by the GenericTypeInference extensions SumHalves — recursion over sub-slices of S with a write through the deepest view, verified against the caller’s array — and AppendKeep.) Every generated named-slice wrapper also implements the non-generic IArray surface explicitly. The public typed Source remains T[] for the concrete wrapper, but the interface member is emitted as Array IArray.Source => ((IArray)m_value).Source!;, matching golib’s IArray.Source contract and keeping len(IArray), element-address helpers, and interface-typed builtins bound to the wrapper. Pointer elements use the same form, e.g. type queue []*item emits ISlice<ж<item>> plus the explicit Array IArray.Source member. (Guarded by NamedSlicePointerElements.)

S where []E is expected. Go assignability lets a named-slice-typed value pass where the unnamed []E is expected (rotateRight(s[m:i], …), pdqsortOrdered(x, …)); the converter materializes such an argument through the SHARING slice<T>(ISlice<T>) constructor — pdqsortOrdered(new slice<E>(x), …) — a cast cannot apply (interface-constrained source; C# forbids user conversions from interfaces). The constructor unboxes a boxed slice<T> directly and otherwise takes the implementer’s full-window interface sub-slice, which every golib implementer returns as a boxed shared slice<T> — NOT Source, which materializes a detached copy (caught by the write-through gate: the helper’s write must land in the caller’s array). The 3-index form on a constrained value emits subslice3<S, E>, and a constrained spread (append(s, v.ꓸꓸꓸ) — a Span<E>) binds an exact params Span<T> twin of the constrained append (betterness otherwise picked the legacy params T[] candidate with T = Span<E>, a ref struct as type argument — CS9244). (Guarded by the GenericTypeInference extension PassSlice — S passed to a concrete []E helper, write-through verified by value vs Go — and by the ConstrainedSliceParamInPlace behavioral test, which drives a full in-place mutation through the materialized slice<E> — an element reversal and a real insertion sort mirroring slices.Sort/SortStableFunc and internal/fmtsort’s make+append-built SortedMap — over plain, named, and []string sequences, asserting the caller observes the reordering. A detached copy would leave the caller’s slice untouched.)

Explicit []E(x) conversion of a ~[]E type parameter. The explicit twin of the assignability case above — Go that spells out the slice conversion (reverse([]E(x)), x of type S ~[]E) rather than relying on assignability — took a different converter path and was broken (CS1503). isTypeConversion’s *ast.ArrayType arm rejects it (a type parameter’s Underlying() is its constraint interface, not []E, so the identical-underlyings gate fails), so it fell through to the general call assembly and rendered slice<E>(x) — the golib array-only builtin slice<T>(T[]), which the ISlice<E>-typed source S cannot satisfy. The converter now intercepts this shape in the general call path (mirroring the sibling string|[]byte-union []byte(x) special case at convCallExpr.go): when the conversion target is a slice-type literal and the sole argument is a *types.TypeParam with a ~[]E slice core (typeParamSliceCore — the same recognizer the implicit path uses), it emits the SHARING new slice<E>(x) constructor, so explicit and implicit ~[]E[]E conversions land identically on the sharing ctor and preserve Go’s slice-conversion aliasing. Genuine conversions are untouched: named-slice casts ([]CaseRange(special)) take the isTypeConversion cast path; string/nil sources are not type parameters; the string|[]byte union is handled by the block just above (typeParamSliceCore is nil for it). Proven output-neutral (all 1696 stdlib .cs byte-identical across an old-vs-fixed reconvert; the behavioral corpus unchanged). (Guarded by the ConstrainedSliceParamInPlace behavioral test’s explicitReverseSeq case — reverse([]E(x)) over plain and named ~[]E sources, which did not compile before the fix.)

An untyped-int literal in a ~[]E-locked type-parameter slot is cast to the resolved element type. A bare untyped-integer literal passed where the parameter is the element type parameter E of a sibling ~[]E-constrained type parameterIndex[S ~[]E, E comparable](s S, v E) called Index(s, 2), or the variadic element of Insert[S ~[]E, E any](s S, i int, v ...E) called Insert(b, len(b)-1, 0) (the slices shape) — drives C# generic inference from the literal’s own C# type. Go infers E from S’s core type ([]intE = Go intnint), but C# has no analogue for ~[]E core-type inference: the emitted where S : ISlice<E> does not flow S’s concrete element to E, so C# infers E SOLELY from the value literal. A bare C# int literal is System.Int32, so Index(s, 2) with s []int made C# infer E=int, and slice<nint> then failed the ~[]int constraint — CS0315 (no boxing conversion slice<nint>ISlice<int>), CS0411 (inference failed), or CS1503 (arg conversion). go/types has already resolved the literal to E’s instantiation (Info.Types[lit].Typeint for the []int caller, byte for a []byte caller, int64long, uintnuint, …), so convCallExpr emits the literal AT that C# type via the shared castArgToType plumbing: Index(s, (nint)(2)), Insert(b, len(b) - 1, (byte)(0)). The sibling-lock gate (typeParamIsSliceElementOfSibling) is what keeps the footprint minimal and correct: the cast fires ONLY when the parameter’s type parameter is the slice-element of another type parameter’s ~[]E constraint — the one shape C# cannot infer. Everything C# already infers correctly is left with its bare literal: a freely-inferred type parameter (First[T any](v ...T)T=int32 satisfies any, and the value is identical), one determined directly by another argument (setThrough[P *T, T any](p P, v T) — C# infers T from the pointer), and an explicitly-instantiated call (NewOption[nint](42) — the type argument is already pinned). A resolved int32/rune kind is skipped even inside the gate (a bare int literal already IS System.Int32 — the []int32 element case stays a plain literal), and a value convBasicLit already casts ((nint)…L for an out-of-int32 constant) is not double-wrapped (the wholeExprIsCastOfType skip in convExprList). The literal-constant test reuses isUntypedNumericConstArg, the same recognizer the append-element and narrow-int casts key off, so a tightened local const — already declared at its concrete type — is excluded. This unblocks the whole slices-package Index/Insert/Replace/Contains-family value-argument seam (cleared the entire CS0315 cluster in the slices Phase-4 test host — 53→40 residual errors, the remainder unrelated classes). Proven zero-drift on the behavioral corpus. (Guarded by the GenericUntypedIntArg behavioral test — Index/appendAll over []int, a named numbers []int, []byte, and []int32 element types with bare int-literal args, which did not compile before the fix; the []int32 case proves the no-cast arm.)

Range-over-func on named/generic Seq types. Go 1.23’s for v := range seq (and the two-value for k, v := range seq2) on an iter.Seq[E]-shaped value emits through golib’s yield-adapting range() overloads. Three pieces make the named/generic form work: detection unwraps the type’s Underlying() (a defined or instantiated func type is a Named, not a bare Signature); a NAMED func type renders as a C# delegate, which has no conversion to the overloads’ Action<Func<…>> parameter — its method GROUP does, so the emission appends .Invoke; and because C# cannot infer a type parameter from a method group’s parameters, the element types are spelled out from the yield signature: foreach (var v in range<nint>(countdown(5).Invoke)). break inside the body ends the foreach, which cancels the adapter’s producer — the yield function receives false, matching Go’s semantics; a two-value range<K, V> overload adapts pair-yields onto the tuple machinery. One adjacent gate was refined en route: a call’s result being a generic instantiation adds explicit type arguments only for conversions and GENERIC callees (NewOption<nint>(42) — an untyped-const arg would infer C# int where Go infers nint), never for a plain function returning a generic named type (countdown<nint>(5) was CS0308). (Guarded by the GenericTypeInference extensions — a generic Seq[V] ranged with break and a two-value KVSeq[K, V], values vs Go.)

An EXPLICITLY-instantiated generic function through a package selector renders its type arguments once. Go’s pkg.Func[T](…) is an IndexExpr (or IndexListExpr for pkg.Func[K, V]) whose base X is the selector pkg.Func. convIndexExpr/convIndexListExpr renders the [T] as <T> itself. But the base is also a generic-function value, so convSelectorExpr — which spells a generic function’s inferred type arguments when it appears as a method-group value (the slices.SortFunc(all, slices.Compare) path, needed because C# can’t infer a method group’s type parameters) — appended <T> a second time, producing pkg.Func<T><T>(). Depending on context this surfaced as CS1525 (reflect.TypeFor[X]() → invalid expression term), CS0119 (a plain-return generic like saferio.SliceCap[T]), or CS8124 (<T>() parsed as a one-element tuple) — ~67 errors across encoding/gob, xml, asn1, json, text/template, database/sql/driver, debug/macho·pe·elf, and unique. The index expression now converts its base with a suppressGenericTypeArgs context flag, so convSelectorExpr skips the value-path append when it is the base of an explicit instantiation (the standalone method-group-value case is unchanged — no flag, still appends). A local generic function (Func[T](), base is an Ident not a selector) never hit this, since only convSelectorExpr appends. (Guarded by the CrossPkgUser extension — CrossPkgLib.Wrap[int](5) (IndexExpr) and CrossPkgLib.Pair[string, int](…) (IndexListExpr), both rendering single type-argument lists, output vs Go.)

Integer type-parameter conversions route through golib (the E(100) family)

C# has no cast to or from a type parameter, so the Go conversions in rand.N[Int intType]Int(x), uint64(n) — and an untyped constant compared against the parameter (n <= 0, which Go types AS Int but C# leaves as int, unacceptable to the lifted IComparisonOperators<Int, Int, bool>) all failed (CS0030/CS0019). Three coordinated pieces, gated on a constraint whose every type-set term has an integer underlying (typeParamIsInteger): a conversion to the parameter emits golib’s runtime-typed ConvertToType<Int>(…) (typeof-dispatch that JIT-folds to a single branch per instantiation; signed kinds sign-extend, unsigned zero-extend — Go’s exact conversion semantics; a [GoType("num:*")] wrapper instantiation falls back to a reflection-cached bridge over its Value property/ctor); a conversion from the parameter to a basic integer emits ConvertToUInt64<Int>(n) (plus a plain numeric cast when the target is not uint64); and a constant operand of a binary op against the parameter materializes via ConvertToType<Int>(0) — except a SHIFT count, which Go types independently and the emission already coerces to int. Result: if (n <= ConvertToType<Int>(0)) … return ConvertToType<Int>(ConvertToUInt64<Int>(n) / 2);. (Guarded by the GenericTypeInference extension halveN~int32 | ~int64 with the compare, both conversions, and a negative value proving sign-extension, values vs Go; clears math/rand/v2’s N.)

A named-wide-integer or type-parameter slice index casts to nint

Go permits any integer type as a slice/array index, converting it to int for the access. The C# slice<E>/array<E> indexer takes nint, and the existing wide-basic index cast already routed uint/uint32/uint64/uintptr/int64 through (nint). Two more index kinds need it, both from internal/trace’s dataTable:

Cleared ~11 of internal/trace’s index CS1503/CS0030 (17→6). The companion shift-count case — 1 << (id % 8) where the count is a numeric type parameter, coerced to int by intCastOperand (the same coercion the shift-width machinery uses) — routes through the same bridge: (uint8)1 << (int)(ConvertToUInt64<EI>(id % 8)) (a bare (int)(EI) is CS0030). Cleared internal/trace’s last two shift-count CS0030 (6→4). Guarded by NamedNumericSliceIndex (a generic lookup[K ~uint64] indexing by the type parameter and an arithmetic result, a pick indexing by a named int64, a num:nint rank index that must stay bare, and a bitset[K ~uint64] shifting by the type parameter, values vs Go).

Method-set interface constraints bind the interface directly; pointer instantiations project through the adapter

A type parameter constrained by a regular method-set interface (a pure method set, no type-term unions — go/ast’s walkList[N Node](v Visitor, list []N)) emits where N : Node against the arity-0 emitted interface. Only union+method constraint interfaces take the generic CRTP form (ConstraintTest1<ΔT>); the method-set arm previously emitted the phantom Node<N>, new(), which was doubly wrong: Node is emitted arity-0 (CS0308), and the instantiation may itself be an interface (walkList takes N=Stmt/Expr/Spec/Decl), which can never satisfy new(). The interface-typed and interface-inheriting instantiations then satisfy the constraint natively (Stmt : Node is emitted inheritance).

A pointer instantiation (walkList(v, n.Names) with N=*Ident) cannot: the ж<Ident> box does not implement the interface — its generated pointer adapter does. The call site projects the slice element-wise through the adapter, instantiating N as the interface itself:

internal static void walkList<N>(Visitor v, slice<N> list)
    where N : Node
{
    foreach (var (_, node) in list) {
        Walk(v, node);
    }
}
// call site, N=*Ident:
walkList(v, widen<ж<Ident>, Node>((~n).Names, elem1 => new IdentжNode(elem1)));

golib’s widen<T, TWide>(slice<T>, Func<T, TWide>) copies the slice HEADER only — elements alias the original objects through the shared boxes, so method calls through the projected slice mutate the real objects. A callee that reassigned list[i] itself would not write back; the projection targets the read/widen shape (Go itself performs the same per-element interface widening inside the loop). convertToInterfaceType supplies the adapter reference and the GoImplement recording, exactly as at scalar *T→iface call sites. (Guarded by GenericInterfaceConstraint — pointer, interface, and embedded-interface instantiations of a method-set-constrained generic, calling a constraint method on the parameter and widening walkList-style, values vs Go; clears go/ast’s CS0308, the go/* toolchain gate.)

A SELF-REFERENTIAL generic method-set constraint uses a box-wrapping proxy as the type argument

The widen-to-the-interface escape above works only when the constraint interface is non-generic (N=Node, so N can be Node). crypto/elliptic’s nistCurve[Point nistPoint[Point]] — where nistPoint[T] is a generic, self-referential method-set interface (Add(T,T) T, SetBytes([]byte) (T,error), …) — cannot: you can’t substitute Point = nistPoint<Point> (infinite regress), and the golib box ж<P224Point> cannot nominally implement nistPoint<ж<P224Point>> (it is a sealed golib type in another assembly, and Go’s structural satisfaction has no C# analog). Four coordinated pieces make it convert and dispatch:

  1. The constraint interface is emitted GENERIC. A method-set interface whose own Go type parameter is used in its member signatures carries its <T> (and constraints) in C#, exactly like a generic struct — [GoType] partial interface nistPoint<T> { … T Add(T, T); (T, error) SetBytes(slice<byte> _); }. Without it the declaration is arity-0 yet the constraint that references it spells the arity-1 where Point : nistPoint<Point> (CS0308) and every bare T is undefined (CS0246). (Go’s operator-only constraint interfaces are arity-0 in Go, so this is disjoint from the <ΔT> operator machinery.)

  2. One GENERIC adapter class implements the outer interface: nistCurveжCurve<Point> : Curve, IжAdapter where Point : nistPoint<Point> wrapping ж<nistCurve<Point>> — NOT a class per instantiation. The converter’s GoImplement records are per-instantiation but all resolve to the open form here, so ImplementGenerator de-dups on the open (struct, interface) pair and forwards its type parameters and the struct’s own constraint (GetGenericConstraintClause). The converter composes the reference name+args separately (nistCurveжCurve<…>, base+ж+iface, then the closed args) so the type arguments do not bake into the identifier (the old nistCurve<…>жCurve was CS1526).

  3. A self-referential PROXY stands in for the type argument. For each concrete pointer type used to instantiate the generic (nistCurve[*P224Point]), the converter renders the type argument as a generated proxy P224PointжnistPoint (element-simple+ж+iface-simple) instead of the box ж<P224Point>, and records [assembly: GoImplement<P224Point, nistPoint<P224Point>>(ConstraintProxy = true)] (the interface’s own argument is a placeholder). ImplementGenerator.EmitConstraintProxy emits:

    internal sealed class P224PointжnistPoint : nistPoint<P224PointжnistPoint>, IжAdapter {
        private readonly ж<P224Point> m_box;
        public P224PointжnistPoint(ж<P224Point> box) => m_box = box;
        public static implicit operator P224PointжnistPoint(ж<P224Point> box) => new(box);
        public static implicit operator ж<P224Point>(P224PointжnistPoint proxy) => proxy.m_box;
        // T rewritten to the proxy itself; the implicit conversions marshal every T-boundary:
        P224PointжnistPoint nistPoint<P224PointжnistPoint>.Add(P224PointжnistPoint a, P224PointжnistPoint b) => m_box.Add(a, b);
        (P224PointжnistPoint, error) nistPoint<P224PointжnistPoint>.SetBytes(slice<byte> b) => m_box.SetBytes(b);
        // …
    }
    

    The proxy implements the interface over itself, so Point = P224PointжnistPoint satisfies where Point : nistPoint<Point> (CS0311 otherwise) and resolves every p.Add(…)/newPoint().SetBytes(…) call inside nistCurve’s body. The implicit ж<P224Point>↔proxy conversions do all the T-boundary marshalling automatically: each forwarder is a bare m_box.M(args) (arguments unwrap to the box on the way in, results rewrap to the proxy on the way out — including element-wise inside a (T, error) tuple), and a value flowing into a Point-typed position (base: Ꮡ(new P224Point(…))) converts implicitly at the site. The proxy forwards to the element’s exported ж-extensions even cross-assembly (m_box.SetBytes binds nistec’s extension from crypto/elliptic).

  4. A func()-typed field’s method-group initializer is re-wrapped as a lambda. nistCurve’s newPoint func() Point becomes Func<P224PointжnistPoint>, but a method group (newPoint: nistec.NewP224Point, returning ж<P224Point>) cannot convert to it — a C# method-group conversion does not apply the user-defined implicit operator (CS0407). Inside a constraint-proxy composite the converter re-wraps such an initializer as a lambda, newPoint: () => nistec.NewP224Point(), whose return position does apply the conversion.

(Guarded by GenericPointerInterfaceImpl — a self-referential curve[Point point[Point]] implementing Curve via pointer receiver, instantiated two ways, with a newPoint func() Point field and a (T, error)-returning constraint method, values vs Go. Embedding the constrained generic and greening the whole crypto-curve family is the next subsection.)

A struct embedding the constrained generic promotes its members — three residual crypto-curve fixes

crypto/elliptic’s p256Curve struct { nistCurve[*nistec.P256Point] } — a non-generic struct embedding a concrete instantiation of the self-referential-constrained generic above — must PROMOTE nistCurve’s internal fields (newPoint, params) and methods (Add/Double/Params/ScalarMult/…) onto p256Curve, exactly as an embed of a plain struct does, so p256.params = … binds and the generated p256Curve→Curve interface adapter can forward curve.Add(…) to the promoted shim. Because the type argument is the box-wrapping proxy of the previous subsection, its rendered name embeds the marker glyph ж (nistCurve<P256PointжnistPoint>) — the thread that runs through all three fixes that made the whole crypto-curve family (elliptic, ecdh, nistec) COMPILE (+3 packages):

  1. The proxy marker glyph ж is not a pointer prefix. The generator’s simple-name / underlying-name helpers (GetSimpleName, GetUnderlyingTypeName) detected a pointer type ж<T> by scanning for a bare ж and slicing from it. The proxy’s own name embeds that glyph mid-identifier (P256PointжnistPoint), so an embed typed nistCurve<P256PointжnistPoint> was mis-sliced into garbage (its simple name became oint.Value, its underlying name an unresolvable string) and the embed promoted nothing (CS1061 on params, CS1929/CS1501 on every forwarded method). Both helpers now match the pointer prefix as the two-character ж<, so a marker embedded in an identifier is left intact.

  2. A generic-instantiation embed resolves to its declaration and substitutes its type arguments. An embed of a generic INSTANTIATION (nistCurve<P256PointжnistPoint>) resolves to the generic DECLARATION (nistCurve<Point>) by base-name + arity (FindStructDeclaration — an instantiation can never string-match a declaration that carries its type PARAMETERS), and a generic struct’s extension methods now match on the type-parameter-bearing receiver (nistCurve<Point>, not the bare nistCurve). The promoted field and method signatures are harvested from the declaration, so they carry its type PARAMETER (Func<Point>, pointFromAffine returning (Point, error)); the template rewrites each to the instantiation’s type ARGUMENT before emission —

    internal ref global::System.Func<P256PointжnistPoint> newPoint => ref nistCurve.newPoint;
    internal static (P256PointжnistPoint p, error err) pointFromAffine(this ref p256Curve target, ж<bigInt> x, ж<bigInt> y)
        => target.nistCurve.pointFromAffine(x, y);
    

    — so no promoted member references the out-of-scope Point. (The member ACCESS hop keeps the bare property name nistCurve; only the emitted TYPE is substituted.) When the ENCLOSING struct is itself GENERIC — wrapped<T> embedding tag<T> (the GenericStructFields guard) — the promoted method is a GENERIC extension method carrying the struct’s own type parameters (static T show<T>(this wrapped<T> target) => target.tag.show();, the substitution then an identity TT), else the T in the receiver and return is an undefined type name (CS0246).

  3. The constraint proxy imports its element’s package namespace. The proxy forwards each interface method to the boxed element’s box extension methods (m_box.Bytes()), which live in the element type’s PACKAGE class (nistec_package, namespace go.crypto.@internal). The [assembly: GoImplement<…>(ConstraintProxy = true)] attribute driving the proxy sits in package_info.cs, whose usings never cover a FOREIGN element, so the forwarders bound nothing (ж<P224Point> “has no Bytes”, CS1929/CS1501). EmitConstraintProxy now emits using <element-namespace>; for the box element’s namespace.

  4. An open-generic interface cast is CONVERTED but not RECORDED. Inside a generic method the receiver itself is cast to the interface — crypto/ecdh’s return newBoringPrivateKey(c, …) with c *nistCurve[Point]. The converter must still WRAP it in the generic adapter (new nistCurveжΔCurve<Point>(Ꮡc) — the adapter the CLOSED per-instantiation records already generate), but must NOT RECORD it as an implementation: a record emits [assembly: GoImplement<nistCurve<Point>, ΔCurve>], whose type-PARAMETER argument Point is out of scope in an assembly attribute (CS0246). convertToInterfaceType now skips the record for an open-generic target while still firing the adapter-wrapping conversion.

(Fix 2 is guarded by the GenericEmbedPromotion behavioral test — a non-generic struct embedding a concrete curve[*p224] over a self-referential proxy: reading a promoted internal field, calling a promoted method whose parameter is the type argument (passed the promoted proxy-typed field), and reaching the promoted methods through a non-generic interface adapter, values vs Go. Fixes 3 and 4 need a cross-package element / a generic-method interface cast the single-package baseline cannot express; they are validated by the census — elliptic, ecdh, and nistec now emit their DLLs, 254 → 257 packages.)

Constraint-only type parameters need explicit type arguments

Go infers a type parameter that appears only in constraints through core types — func Twice[S ~[]E, E Integer](s S) infers E from S’s underlying element; the slices package’s whole Sort[S ~[]E, E cmp.Ordered] → pdqsortOrdered chain relies on this. C# never infers a type parameter that does not appear in the parameter list (CS0411 — at every call site, concrete instantiations included). When the callee declares such a constraint-only type parameter, the converter renders the call’s type arguments explicitly from the instantiation go/types already resolved (info.Instances): Twice<Point, int32>(p, 2) at a concrete site, Scale<S, E>(s, c) inside a generic body. Calls to generics whose every type parameter is argument-visible keep their bare Go-shaped form — C# infers them as Go does, no churn. (Guarded by the GenericTypeInference extension — a constrained S/E pass-through chain plus a concrete call to a constraint-only-param generic, values vs Go; clears the 14 CS0411s in the slices/maps wave.)

The same explicit-type-argument rule applies to a generic function referenced as a method-group value, not just a call. slices.SortFunc(all, slices.Compare) (runtime/pprof) passes slices.Compare[S ~[]E, E cmp.Ordered] as SortFunc’s comparison delegate; C# cannot infer a generic method group’s constraint-only E when converting it to Func<…> (CS0411). convSelectorExpr now spells the arguments on the selector — slices.Compare<slice<uintptr>, uintptr> — when the selector is NOT the callee of a call (!context.isCallExpr, so convCallExpr’s own type-arg site still owns the call form) and info.Uses[Sel] is a generic function with an info.Instances instantiation. Byte-identical across the behavioral corpus and across an A/B of pprof+slices+sort+maps+cmp+net+go/types (a single line moves — the slices.Compare argument; every Compare(...) call stays bare). GUARD OWED — the shape needs a cross-package generic function with a constraint-only type parameter passed as a method-group value, which the single-package baseline cannot express; the bare-IDENT variant (a same-package generic func passed as a method group) is a parallel latent case left unfixed because convIdent, unlike convSelectorExpr, carries no call-vs-value flag to gate against double-emitting a direct call’s arguments.

The comparable constraint

Go’s built-in comparable admits every ==-able Go type — numerics, strings, pointers, channels, and comparable structs/arrays/interfaces. No C# constraint can express that set: golib’s old comparable<T> CRTP interface was implemented by nothing (every real instantiation failed — maps.Keys[M ~map[K]V, K comparable] could not be used at all), and lifting IEqualityOperators would reject structs, which Go admits. A comparable type parameter therefore emits no C# constraint beyond the standard new()where K : /* comparable */ new() — relying on the two facts that make it sound: Go’s checker already validated every instantiation, and emitted equality on type parameters routes through AreEqual, never operator ==.

AreEqual itself is not a performance tax on that path: a generic overload AreEqual<T>(T, T) — automatically preferred by overload resolution exactly where both operands share the type parameter — takes EqualityComparer<T>.Default.Equals for value-type arguments, which the JIT specializes per type and devirtualizes to the type’s own IEquatable<T> (operator-comparable speed, no reflection or boxing; golib wrappers emit operator == and Equals as consistent pairs, so semantics match). Reference/interface type arguments delegate to the reflective AreEqual(object, object) overload, preserving its typed-null and runtime-type semantics. (A constraint-differentiated overload pair is not expressible — C# treats where clauses as outside the signature, CS0111 — and a source-generated == twin is unnecessary given the EqualityComparer<T>.Default JIT intrinsic.) (The behavioral GenericVariadicFunc golden captures the erased form with unchanged output.)

Floating-point equality follows Go’s IEEE-754 ==, not Equals. The Equals-based fast path above is wrong for exactly one family: double/float report NaN.Equals(NaN) as true (and Complex/golib complex64 inherit that componentwise), while Go’s == — the operation AreEqual stands in for — is IEEE: NaN compares unequal to everything, itself included. That inverted every generic NaN probe of the x != x form: cmp.isNaN emits !AreEqual(x, x), so cmp.Less lost its NaN-first ordering (sort’s TestFloat64sFloat64sslices.Sort produced a NaN-scrambled order) and cmp.Compare reported NaN equal to everything, which let the mis-sort slip past TestSortFloat64sCompareSlicesSort’s own equality check. The generic overload now special-cases double, float, complex128, and complex64 to the operator compare (JIT-constant typeof(T) guards, box-cast elided). The reflective object overload (boxed/interface comparisons) was already IEEE-correct and stays untouched: on .NET 7+ the primitives DECLARE op_Equality (the IEqualityOperators implementation), so its cached operator lookup finds the real operator rather than falling to Equals. Concrete (non-generic) float comparisons were always correct — f != f emits the C# operator directly. (Guarded by the ReverseSortNaNOrder behavioral test — generic isNaN/less/eq legs over float64/float32/complex128/ complex64, boxed-any NaN equality, and a NaN-aware interface sort, values vs Go.)

Generic struct equality is decided per FIELD, not per type parameter

A generic [GoType] struct’s synthesized Equals (see Struct Types) was gated on the struct’s TYPE PARAMETERS: unless every parameter carried an IEqualityOperators-implementing constraint (and, stricter still, every constraint of every parameter implemented it), the whole struct’s Equals body was the constant false /* missing equality constraints */. Since a comparable parameter deliberately emits no C# constraint beyond new() (previous subsection), essentially every generic struct in the corpus — all 22 generic [GoType] declarations at the time of the fix — carried a constant-false Equals, breaking equality that never depended on the parameter at all. unique.Handle[T]’s only field is *T (ж<T>), whose pointer-identity == is valid for every T, yet no two handles ever compared equal — directly contradicting the type’s documented contract (“two handles compare equal exactly if the values used to create them would”); internal/weak.Pointer[T]’s only field does not mention T at all. Even internal/trace.dataTable[EI, E] — whose EI explicitly lists IEqualityOperators — failed the every-constraint quantifier because IAdditionOperators and its siblings do not themselves implement the equality interface.

The gate now decides per member (GetEqualityFallbackMembers in StructDeclarationSyntaxExtensions): a member whose type supports == independent of the unconstrained parameters keeps the same this.f == other.f compare a non-generic struct emits, and only a member whose type IS an unconstrained type parameter falls back to golib’s AreEqual — the identical routing the converter emits for Go == on any type-parameter operand, giving EqualityComparer<T>.Default speed on value types while preserving IEEE float semantics (raw EqualityComparer reports NaN equal to itself, inverting Go — see the floating-point note above) and typed-null/runtime-type semantics for reference and interface instantiations. Real emissions (from the converted stdlib’s generated sources):

// unique.Handle<T> — ж<T> has pointer-identity == for every T:
public bool Equals(Handle<T> other) =>
    this.value == other.value;

// database/sql.Null<T> — mixed: the T field routes through AreEqual, the rest keep ==:
public bool Equals(Null<T> other) =>
    global::go.builtin.AreEqual(this.V, other.V) &&
    this.Valid == other.Valid;

// net/http.mapping<K, V> — golib slice/map fields carry their own ==, so no member falls back:
public bool Equals(mapping<K, V> other) =>
    this.s == other.s &&
    this.m == other.m;

The member classifier asks only “does == COMPILE for this member type”: a type parameter qualifies through ANY IEqualityOperators-implementing constraint (matching C# operator resolution, not the whole-struct gate’s every-constraint test); reference types (classes incl. ж<T> and unsafe.Pointer, interfaces, arrays, delegates), enums, pointers, and built-in value types always qualify; a [GoType] struct qualifies by its attribute — both struct templates emit a same-type operator == unconditionally, and for a struct of the same compilation the attribute is the only visible evidence, because that operator does not exist yet while the generator runs; any other value type qualifies only by actually declaring a same-type op_Equality. Structs that passed the old whole-struct gate, and every non-generic struct, emit byte-identical bodies to before — the fallback set is computed only when the gate fails. GetHashCode needs no matching change (golib.HashCode.Combine always compiled and hashes consistently with both compare forms). (Guarded by the GenericStructEquality behavioral test — the Handle pointer-identity shape, the plain-T fallback shape, a T-independent-field struct, the Null-shaped mix, a nested generic struct field, and a generic struct as a map key, all output-compared vs Go.)

A generic struct implementing an interface BY VALUE partials at its OPEN definition

A Go method on a generic type is declared for every instantiation, so func (g G[T]) M() makes G[int], G[string] and G[G[int]] all satisfy an interface with M. The converter records a [assembly: GoImplement<…>] per instantiation it sees, and ImplementGenerator’s value-form arm wrote one partial struct per record, spelled with the record’s TYPE ARGUMENTS: partial struct G<IntPtr> : I. C# reads that argument list as a type-parameter list, so the declaration disagrees with the converter’s own partial struct G<T> (CS0264) and the mismatched parts stop merging — every member the template writes then lands in the containing static package class instead (CS0715 on the operators, CS0708 on Equals/GetHashCode/ToString, CS0563 and CS0540 in the cascade). The arm now emits ONE partial against the open definition, keyed by (OriginalDefinition, interface) so all instantiations of a pair fold into it; the member and value-pair dedupe indexes key on the same open form, since two interfaces over one open generic share a single partial. Constraints are deliberately omitted — a partial declaration may leave them off and they merge from the converter’s declaration, so omission can never raise CS0265. The pointer-adapter arm had always done this (emittedGenericPointerAdapters, crypto/elliptic’s nistCurve[Point]); this is its value-form sibling.

Behind it sat a second, independent defect in the shared GetSimpleName helper, and it is the one that explains why the two packages holding this class both name their generic with a single letter. Asked to drop a type-argument list, the helper tested typeName.IndexOf('<') > 1 — so G<T>, whose < sits at index 1, kept its arguments. StructTypeTemplate derives the constructor name from that call, and emitted public G<T>(NilType _), which is not a constructor to C#: the partial struct G<T> scope never opens and the same spill follows. Every multi-character generic in the corpus (meta<T>, nistCurve<Point>, Handle<T>) cleared the guard, which is why this survived to the first single-letter one. The guard is now > 0 and indexes the simple name rather than the full one — the latter also closes a latent, currently unreached miscut on a dotted generic (a.Map<K, V> indexed at 5 into an 8-character Map<K, V>, yielding Map<K).

Measured on internal/reflectlite (type B[T any] struct{}) and runtime/debug (type G[T any] struct{} with var dummy I = G[int]{} and var dummy2 I = G[G[int]]{}), the two packages the board recorded behind one CS0715 root. runtime/debug moves from build-blocked to a measured 2 of 9; internal/reflectlite clears this root and stops on five unrelated ones. Guarded by the GenericValueInterfaceImpl behavioral test — a single-letter generic held as an interface at three instantiations, a sibling type named exactly like the type parameter (the runtime/debug shape that made the spilled members render as debug_test_package.T), struct equality, struct-versus-interface comparison, and interface dispatch over a mixed slice, all output-compared against go run.

The string | []byte union

C# generic constraints are conjunctive (“and”), so they cannot express Go’s string | []byte union directly. The two members share no operators (the union is neither comparable nor additive), so a conforming body may only use the read operations common to both — indexing, len, and sub-slicing. These are captured by the golib read-only byte-sequence interface IByteSeq, which both @string and slice<T> implement; the converter emits it for the union and suppresses the (spurious) lifted operator constraints:

func HashStr[T string | []byte](sep T) uint32 { /* uses sep[i], len(sep) */ }
public static uint32 HashStr<T>(T sep)
    where T : /* string | []byte */ IByteSeq<T, byte>, new()
{ /* … */ }

The constraint is self-referential — the C# rendering of the CRTP shape, IByteSeq<TSelf, T> : IByteSeq<T> where TSelf : IByteSeq<TSelf, T>, instantiated at the type parameter itself. @string implements IByteSeq<@string, byte> and slice<T> implements IByteSeq<slice<T>, T>, so the sub-slice indexer returns the concrete sequence type rather than the interface. That extra type parameter exists purely to keep the union allocation-free (next subsection).

len resolves through a len<TSeq>(TSeq) where TSeq : IByteSeq overload that is dispreferred for concrete slice<byte>/@string arguments, so existing call sites keep their specific overloads (no ambiguity). Go’s []byte(s) and string(s) over a constrained value render as the golib extensions s.ToSlice() and s.ToGoString(), each generic over the caller’s concrete type; both preserve Go’s per-instantiation semantics exactly ([]byte([]byte) shares its backing, []byte(string) copies, string(string) is free, string([]byte) copies). The behavioral test StringByteUnionConstraint exercises both the string and []byte instantiations across all of these.

Allocation-free union-constrained bodies

The interface was originally single-parameter (IByteSeq<T>) with an IByteSeq<T> this[Range] sub-slice indexer. Every member whose signature named the sequence type therefore forced a box, and Go bodies over this union sub-slice constantly:

Emitted shape Boxed Why
((bytes)(s[a..b])) 48 B (slice<byte>) / 24 B (@string) the range indexer returned the INTERFACE, so the struct result was boxed — and the cast then unboxed it
len(s) one box per call the overload took IByteSeq<T>, an interface parameter
new slice<byte>(s) — Go’s []byte(s) one box per call the constructor took IByteSeq<T>
new @string(s) — Go’s string(s) one box per call the constructor took IByteSeq<byte>

A parseRFC3339-shaped body (seven sub-slices, eight len calls, six []byte(s) conversions per parse) measured 720 B/parse on the slice<byte> instantiation and 776 B/parse on @string — for Go code that allocates nothing. The remedy is one idea applied four times: never name the sequence type as an interface in a signature a generic body reaches.

Measured after: 0 B/parse on slice<byte>, and 776 → 416 B/parse on @string (what remains there is the byte[] each []byte(string) conversion must materialize — Go copies too). Guarded by GolibTests ByteSeqAllocationTests, which asserts the measured bytes and carries a deliberately-boxed control that must still report the old 720 B/parse. That control pins its box behind a [MethodImpl(NoInlining)] boundary on purpose: the JIT elides a box/unbox pair written adjacently in one method, so an inline control would report 384 B/parse and understate what the redesign is worth. The real interface indexer boxed inside a callee and returned, which is why the cost was really paid.

Explicit type arguments come from the callee’s instantiation

A generic function’s explicit type arguments are read from the CALLEE’s resolved instantiation (info.Instances), not from the RESULT type’s arguments – the two lists differ whenever the callee has more type parameters than the result names. reflect’s rangeNum[T, N](num N) iter.Seq[T] called rangeNum[int8](v): the result Seq[T] carries ONE argument where the method needs TWO (CS0305). The result’s own arguments still gate whether to emit, so a generic callee returning a plain named type keeps C# inference:

return rangeNum<int8, int64>(v.Int());

Guarded by GenericTypeInference (seqOf[T ~int64, N ~int32 | ~int64](n N) Seq[T]).

Increment/decrement on a type parameter

i++ / i-- on a constrained type parameter binds IIncrementOperators<T> / IDecrementOperators<T>, which the lifted Arithmetic operator set now includes (reflect rangeNum’s loop, CS0023). They live in the numeric-only Arithmetic set – never the string-including Sum set, since @string implements neither. The list is emitted in two places that must stay in sync: the converter’s getLiftedConstraints (constraintOperations.go) and the go2cs-gen InterfaceTypeTemplate.

Unary negation on a type parameter

-x on a constrained type parameter binds IUnaryNegationOperators<T, T>, which the lifted Arithmetic operator set now includes (math/rand/v2’s func keep[T int | uint | int32 | uint32 | int64 | uint64](x T) T { return -x }, CS0023). Like increment/decrement it is numeric-only — @string has no negation — so it never joins the Sum set.

Satisfying it needed a matching change on the generator side, because a NAMED Go numeric type is instantiated through its go2cs-gen wrapper. Go defines -x on EVERY numeric type, unsigned included, as the wrap-around 0 - x; C# has no unary minus for ulong at all and widens uint/ushort/byte to a signed type, which is why NumericTypeTemplate previously emitted the operator only for signed types. It now emits the unsigned form as that subtraction under unchecked — exactly Go’s semantics — so a generic over ~uint64 instantiated with a named unsigned type (internal/trace’s dataTable[EI ~uint64, E] over type stringID uint64) satisfies the constraint instead of failing CS0315:

// generated for `type counter uint64`
public static counter operator -(counter value) => (counter)unchecked((uint64)((uint64)0 - value.m_value));

The list is emitted in THREE places that must stay in sync: the converter’s getLiftedConstraints (constraintOperations.go), the go2cs-gen InterfaceTypeTemplate “Arithmetic” constraint list, and InheritedTypeTemplate’s NumericInterfaces declaration list (whose operator bodies come from NumericTypeTemplate). Guarded by GenericNegation, which negates across the primitive widths and through named types over both a signed (~int32) and an unsigned (~uint64) underlying type, output-compared against Go so the unsigned wrap is verified rather than merely compiled.

uintptr as a generic numeric type argument

The golib uintptr struct declares the full generic-math interface set the lifted numeric constraints demand (IAdditionOperators through IComparisonOperators, IShiftOperators<uintptr, int, uintptr> with a >>> operator, IIncrementOperators/IDecrementOperators) – matching operators alone never satisfy a C# where-clause (CS0315 at reflect’s rangeNum<uintptr, uint64>). At runtime, ConvertToType/ConvertToUInt64 have uintptr fast paths, and the reflection-cached TypeParamCaster probes a public Value FIELD as well as the generated wrappers’ Value property (hand-written wrappers keep a field for Interlocked/Volatile ref x.Value seams). Guarded by GenericTypeInference (growShrink[U ~uint32 | ~uintptr]).

Latent gap (banked): generated [GoType("num:*")] wrapper structs do NOT yet declare the generic-math interfaces – a NAMED numeric wrapper used as a union-generic type argument would CS0315. No corpus site hits this yet.

Union-constrained sub-slices cast back to the type parameter

A sub-slice of a string | []byte union-constrained value is typed by Go as the type parameter again, so it assigns back to, passes as, and returns as the parameter (time format_rfc3339, CS0266/CS0310/CS0029 before the cast landed). The emission wraps the range forms in an explicit conversion to the type parameter:

return parse(((T)(s[0..2]))) + parse(((T)(s[3..5])));

Func-literal parameters typed as the union type parameter render as the parameter itself (the enclosing method’s type parameter is in scope inside a lambda), matching the Go:

var parse = (T part) => {

Since the constraint became self-referential (IByteSeq<T, byte>, above), the Range indexer returns T directly, so this cast is an identity conversion that emits no IL — it was a runtime-checked unbox of a boxed struct when the indexer returned the interface. The emission is kept because it names the type Go gives the expression; it no longer costs anything.

Guarded by StringByteUnionConstraint (trimHead/headSum; digitSum).

Spreading a union-constrained value

A union-constrained value may also be spread into a variadic — encoding/json’s appendString[Bytes []byte | string] does append(dst, src[lo:hi]...) (and the open-ended append(dst, src[lo:]...)). The sub-slice is typed as the type parameter again, so the cast-back above wraps it, and the spread renders as ((Bytes)(src[lo..hi])).ꓸꓸꓸ. A bare type-parameter value has no members of its own, so the spread ꓸꓸꓸ (which yields the Span<byte> the append<T>(slice<T>, params Span<T>) overload binds) must be declared on the constraint interface — a member access on a constrained type-parameter value resolves through its constraint. IByteSeq<T> therefore exposes Span<T> ꓸꓸꓸ { get; }; both implementers already satisfy it (slice<T> as Span<T>, @string as Span<byte>), so the interface member is implicit and adds no cast (CS1061 otherwise — the type parameter Bytes had no ꓸꓸꓸ). (Guarded by the StringByteUnionConstraint extension appendRun — a bounded and an open-ended sub-slice of the union value spread into append, both instantiations value-compared vs Go.)

Type Aliasing

Go supports two kinds of type aliasing: a “type definition” and a “type alias declaration”.

Type Definitions

For a Go “type definition” the new type is a distinct type that shares an underlying type with its base. Because converted types are structs (no inheritance), the converter relies on the source generators (see Source Generators) to emit the bridging needed for these to be used interchangeably while remaining distinct: implicit conversion operators down to the underlying type (via ImplicitConvGenerator / TypeGenerator), and, where the base is a built-in like slice, the relevant interface (ISlice<T>, etc.) implementation. A named type also supports the extension methods (receiver functions) of its underlying types, which the generators surface as proxy/overload methods.

When a pointer conversion (*Target)(srcPtr) bridges two structurally-identical structs, the converter records an indirect (boxing) implicit conversion Source → ж<Target> and ImplicitConvGenerator emits implicit operator ж<Target>(Source src) => Ꮡ(new Target(<members>)). For a self-boxing conversion — Source and Target are the same struct (mspan → ж<mspan>), which arises from a self-referential struct’s recursive sub-struct conversions — that member-by-member reconstruction is both unnecessary and wrong: a pointer field whose target ctor parameter is itself a ж<…> was deref’d (src.f?.Value ?? default!), and a value cannot bind a pointer parameter (CS1503). The generator detects self-boxing (the boxed element type equals the source) and emits Ꮡ(src) instead — boxing a copy of the whole struct directly, identical in effect for a pointer-free struct and correct for one with pointer fields. (Validated by the green baseline build, which regenerates every .g.cs, plus the TypeConversion behavioral test for the non-self-boxing form; runtime exercised self-boxing for mspan, g, stackScanState, hmap, etc.)

Type Alias Declarations

For a Go “type alias declaration” the alias matches C# aliasing implemented with the using keyword. Since the alias may be exported and referenced across files, the converter emits a global using (C# 10’s Global Using Directive) into the package’s generated aliases. For example:

type P = *bool
type M = map[int]int
type table = map[string]int
global using P = go.ж<bool>;
global using M = go.map<nint, nint>;
global using table = go.map<@string, nint>;

A global using RHS renders csproj-alias numerics as C# keywords. C# resolves a using directive’s target without reference to other using directives — aliases are invisible to one another — so the golib csproj-level aliases (uint64, float64, any, …) that resolve everywhere else in the compilation are CS0246 inside global using X = …;. The alias-declaration emission (only) rewrites those names to their using-safe keyword/BCL equivalents: fiat’s type p224UntypedFieldElement = [4]uint64 emits global using p224UntypedFieldElement = go.array<ulong>;. Body code keeps the Go-visual alias names; already-safe names (byte, bool, nint, go.@string) are untouched, and the rewrite deliberately skips dot-qualified names so a package type sharing a builtin name is left alone. (Guarded by the AliasStructComposite extension words — an alias to [4]uint64 used as a parameter type, output-compared.)

An alias to an unnamed array/slice resolves through types.Unalias at type-switched decision points. An alias is a *types.Alias (Go 1.22+) — neither the AST’s ast.ArrayType nor the resolved *types.Array — so an emission that type-switches on the syntax node or the unresolved type misses it. Three sites resolve through types.Unalias: (1) the range operand dispatchfor _, e := range w on an alias-typed array previously matched no arm and emitted the whole loop as a C# comment, a silent behavioral hole; it now emits the normal foreach (var (_, e) in w). (2) the composite-literal dispatchwords{10, 20, 30, 40} emits the same element-array projection the unnamed literal uses, new uint64[]{10, 20, 30, 40}.array(); the alias name renders as an Ident (not an ast.ArrayType), so it cannot take the composite-initializer bracket rewrite, and keeping it produced a C# collection initializer on the alias (new words{…} — CS1061, array<T> has no Add). (3) var declarations — a local or package-level var w words allocates the fixed-size backing (words z = new(4); / internal static words gw = new(4);) instead of default!/uninitialized, whose null backing array throws NRE on the first element write. An alias to a named type is unaffected: unaliasing lands on *types.Named and the existing wrapper-struct arms apply, with the alias name preserved. (Guarded by the AliasStructComposite extensions — alias-typed range loop, composite literal, and local + global var with element writes, values vs Go.)

A same-package alias TARGET carries the package’s FULL namespace, not just its class. An exported alias whose target is lifted into the package class — type CorpusEntry = struct{…} lifts its anonymous struct to a nested CorpusEntryᴛ1 (and the same for an alias to a same-package named type) — must qualify that target with the package’s whole namespace. For a package in a nested namespace (internal/fuzz → namespace go.@internal, class fuzz_package; net/httpgo.net / http_package) the lifted type lives at go.@internal.fuzz_package.CorpusEntryᴛ1. Building the qualifier from the bare <pkg>_package class alone dropped the intervening namespace segment, so the emitted global using CorpusEntry = go.fuzz_package.CorpusEntryᴛ1; — and the matching [assembly: GoTypeAlias("CorpusEntry", "go.fuzz_package.CorpusEntryᴛ1")] that every consumer replays verbatim through its <ImportedTypeAliases> block — named a namespace that does not exist → CS0234 at the using-alias line and at every use (internal/fuzz’s CorpusEntry, ×60). The qualifier is now taken from the same packageNamespace that emits the namespace …; declaration (minus the root, plus the class), so the target and the declaration always agree: go.@internal.fuzz_package.CorpusEntryᴛ1. A top-level package’s namespace is exactly the root (go), leaving no intervening segment, so its target stays go.<pkg>_package.… — the emission is byte-for-byte unchanged there. (Guarded by the NestedAliasUser behavioral test — a top-level package main that imports its own nested inner subpackage, whose C# namespace is go.NestedAliasUser; inner exports an anon-struct alias Entry, and both inner’s own global using and the consumer’s imported global using innerꓸEntry resolve to go.NestedAliasUser.inner_package.Entryᴛ1, values vs Go.)

Delegates to Value Receiver Instances

A Go METHOD EXPRESSION(*timers).run, the unbound method as a func value whose first parameter is the receiver (runtime time.go’s abi.FuncPCABIInternal((*timers).run)) — selects a method off a type. Emitting the selector naively renders the type in value position ((ж<timers>).run — CS0119 + CS1503). Go types the expression as the func signature with the receiver prepended, so the converter renders that signature as the concrete delegate type and casts the method’s static form to it: (Func<ж<timers>, int64, int64>)(run). For a [GoRecv] method the RecvGenerator’s ж-overload matches the delegate exactly; a value-receiver method expression (counter.get) casts to its value-typed delegate ((Func<counter, nint>)(get)); a direct-ж method’s primary form matches directly. (Guarded by the MethodExpression behavioral test — pointer- and value-receiver method expressions assigned, passed inline, and invoked, with mutations accumulating through the receiver box, values vs Go.)

A method expression on a FOREIGN type(*http.Request).Write (net/http/httputil persist.go), or (*Reader).ReadBytes in an EXTERNAL test (package bufio_test) that dot-imports bufio — must additionally qualify the method’s static form: the [GoRecv]/extension static (and its RecvGenerator ж-overload) lives in the defining package’s class, so the bare name is CS0103 (a using static imports an extension method only for recv.M() invocation, never as a bare method group — so (Func<…>)(ReadBytes) cannot bind it; a same-named local method would instead mis-bind, CS0123). The qualifier is derived from the method’s OWN package via go/types (importQualifier(obj.Pkg().Name()), with the file’s import-alias override) — identical to how getAliasQualifiedTypeName qualifies the receiver type inside the delegate (bufio.Reader) — rather than by peeling the Go source spelling: a dot-imported type is a BARE ident (Reader), not a pkg.T selector, so the old source-peel silently dropped the qualifier and emitted the bare name. The result is (Func<ж<http.Request>, io.Writer, error>)(http.Write) / (Func<ж<bufio.Reader>, byte, (slice<byte>, error)>)(bufio.ReadBytes). A same-package method expression keeps the bare name — the static is in scope — so existing emissions are unchanged. (Guarded by the CrossPkgUser extension — pointer-receiver (*CrossPkgLib.Sensor).Calibrate (write observed through the original receiver) plus value-receiver Sensor.Hot / Celsius.Add foreign method expressions, each invoked through its func value, output-compared vs Go — and by the MethodExprDotImport behavioral test, which dot-imports a sibling package and uses (*Reader).Read / (*Reader).Peek as func values; the pre-fix converter fails it with bare (Read)/(Peek) — CS0103 — exactly the bufio TestUnreadByteOthers failure.)

The bound method valued.compute = metricReader(read).compute (runtime metrics.go), types.MethodVal used as a value — forwards through a lambda that captures the receiver expression and carries the method’s own parameters, explicitly typed: (ж<statAggregate> p1, ж<metricValue> p2) => ((metricReader)read).compute(p1, p2). The previous emission hardcoded arity zero (() => x.m()), mismatching any non-nullary target delegate (CS1593). One documented divergence: the receiver expression is evaluated inside the lambda (per call), where Go binds it once at method-value creation — acceptable for the compile milestone and the simple receivers observed. (Guarded by the MethodExpression extension — a bound c.add invoked repeatedly, mutations accumulating through the bound receiver, values vs Go.)

An INTERFACE-receiver method value in assignment context is exempt from that lambda: an interface method is a genuine C# instance method, so a plain method group over the evaluated receiver expression both compiles and matches Go’s bind-once semantics exactly — f = conf.Sizes.Alignof (go/types sizes.go, conf the ref receiver) emits f = conf.Sizes.Alignof;, evaluating conf.Sizes once at delegate creation. The synthesized lambda there was doubly wrong: it re-evaluated the receiver per call and captured conf — capturing a ref receiver is CS1628. This mirrors the value-context rule below, which already leaves interface receivers on the plain emission; whole-stdlib footprint of the change: sizes.cs ×6, database/sql convert.cs, debug/buildinfo, net/http h2_bundle — every hunk a lambda collapsing to its method group. (Guarded by the IfaceFieldMethodValueBind behavioral test — a method value on an interface field of a pointer receiver with the field REBOUND after the value is taken, proving bind-once, output-compared vs Go.)

A POINTER-receiver method value in a value context — passed as a call argument rather than assigned: s.nonDefaultOnce.Do(s.register), registerMetric(…, s.nonDefault.Load) (internal/godebug) — cannot use the bare selector: the [GoRecv] emission is an extension method whose first parameter is a value type, and C# cannot create a delegate from that (CS1113/CS1061). Go binds the receiver address once at method-value creation (s.register(&s).register), so the converter emits exactly that binding as a box-bound method group over the RecvGenerator’s ж-overload (class-typed, delegate-legal): Ꮡs.register for the receiver itself, Ꮡs.of(Setting.ᏑnonDefault).Load for a receiver value-field chain (the &x.field machinery renders the real field box). Unlike the assignment-context lambda above, this form matches Go’s bind-once semantics exactly. A method whose body contains such a method value on its own receiver (or a value-field chain of it) is promoted to direct-ж by the capture-mode pre-pass (bodyHasPointerMethodValueOnReceiver) so the receiver box Ꮡrecv exists in scope. (Guarded by the ReceiverFieldMethodCall extension — method values on the receiver, on a receiver value field, and on a boxed local’s field, passed as func values and invoked with mutations landing on the real storage, values vs Go.)

The VALUE-receiver analog captures rather than binds. When a value-receiver method value roots at the enclosing method’s receiver — kdf.hash.New (crypto/internal/hpke’s hkdfKDF, whose hash field is a crypto.Hash and New a value-receiver method; also crypto/tls’s c.hash.New) — the emitted method is an extension over a value receiver, which likewise has no C# delegate (CS1113), so the converter synthesizes a wrapping lambda carrying the method’s own parameters: () => kdf.hash.New(). But that lambda captures the receiver, and a non-direct-ж pointer-receiver method renders this ref hkdfKDF kdf whose ref var kdf = ref Ꮡkdf.Value alias cannot be captured by a C# closure (CS1628 — “cannot use ref/in/out parameter inside a lambda”). So a method whose body contains such a method value is promoted to direct-ж by the capture-mode pre-pass (bodyCapturesReceiverInValueMethodValue, the value-receiver sibling of bodyHasPointerMethodValueOnReceiver), giving it a receiver box Ꮡkdf that the synthesized lambda references as a capturable reference: () => Ꮡkdf.Value.hash.New(). Two supporting pieces make the receiver render through its box inside the synthesized lambda (which has no *ast.FuncLit node): the capture-analysis walk now marks the field-chain root receiver box-ref, not only a bare-ident receiver (kdf.hash.New roots at kdf, not a bare ident); and the value-receiver synthesis renders the receiver expression in a lambda-conversion context (conversionInLambda) so convIdent emits the Ꮡkdf.Value box form. Same documented divergence as the other method-value forms — the receiver expression re-evaluates inside the lambda (per call), where Go’s value-receiver method value binds a copy of it once at creation; acceptable for the compile milestone (the closure sees the same receiver instance, matching the pointer-receiver semantics of the enclosing method). This also cleared the identical latent CS1628 in crypto/tls’s key_schedule.cs (expandLabel/extract/finishedHash each pass c.hash.New to hkdf/hmac, and the direct-ж fixpoint promoted their callers deriveSecret/trafficKey/… with call sites adapting to Ꮡc.expandLabel / Ꮡsuite.trafficKey). (Guarded by the ReceiverCapturedInClosure extension — a pointer-receiver method capturing its receiver through a value-receiver method value on a value field-chain (w.id.render) and on the bare receiver (w.tag), alongside the pre-existing func-literal capture, all invoked and output-compared vs Go; whole-stdlib reconvert diff: exactly hpke + the two crypto/tls files changed, nothing else.)

The go-statement sibling of the receiver-capture family: a go statement calling a value-returning method through the enclosing method’s pointer receiver — go q.conn.HandshakeContext(ctx) inside func (q *QUICConn) Start (crypto/tls quic.go, CS1628) — is FORCED into the synthesized-lambda emission because goǃ has only void Action overloads (the x/net/nettest CS0407 form): goǃ(ᴛ1 => q.conn.HandshakeContext(ᴛ1), ctx). That lambda references the receiver exactly like the method-value cases above, but neither closure predicate sees it — there is no *ast.FuncLit and no method-VALUE expression, only a go-call whose lowering will synthesize one. The capture-mode pre-pass therefore also promotes on bodyHasGoStmtLambdaCapturingReceiver, which mirrors visitGoStmt’s lambda-form decision (a nullary call synthesizes a lambda only for a value-returning or named-func-type callee; a call with arguments does so when the callee returns a value or the arity mismatches — variadic never matches) and fires when the CALLEE expression references the receiver (arguments render outside the lambda, as goǃ call arguments). With the method direct-ж, the go-stmt capture analysis’ existing box-ref marking of the receiver (varIsDerefdPointerParam) takes effect and the lambda renders the chain through the box: goǃ(ᴛ1 => Ꮡq.Value.conn.HandshakeContext(ᴛ1), ctx). The method-group emissions are excluded and unchanged — a void matching-arity callee (os/exec’s go c.watchCtx(resultc)) binds the receiver chain at delegate-creation time, outside any lambda; a defer sibling needs no equivalent because any function-level defer already promotes via bodyWrappedInDeferContext. Known divergence (Phase-4 item): the synthesized lambda reads the receiver chain (Ꮡq.Value.conn) at goroutine-run time, whereas Go evaluates the method-value receiver at go-statement timego q.conn.M(x); q.conn = other deterministically calls the OLD conn in Go but races toward the NEW one here (arguments are statement-time in both). The same lazy-chain window already exists for every synthesized go-lambda over a non-receiver chain (the CS0407 discard form); the faithful fix for the whole class is hoisting the receiver-chain prefix into a statement-time temp before the lambda, which would also avoid the direct-ж signature flip — direct-ж was chosen here for machinery reuse under the compile-first milestone. (Guarded by GoStmtReceiverLambdago e.tally.bump(delta) (argument arm) and go e.tally.report() (nullary arm), both value-returning through a pointer field of the receiver, with the goroutine’s writes read back through the original receiver chain, output-compared vs Go.)

A conversion to a named func typemetricReader(read) where type metricReader func() uint64 — targets a C# delegate declaration (internal delegate uint64 metricReader();). Distinct delegate types have no cast conversion (a (metricReader)read from Func<ulong> is CS0030); C# converts via delegate creation: new metricReader(read), which accepts a compatible delegate or method group. The general conversion branch special-cases a named target whose underlying is a *types.Signature. Composed with the bound-value lambda this renders the full runtime metrics.go registration: d.compute = (ж<statAggregate> p1, ж<metricValue> p2) => new metricReader(read).compute(p1, p2). (Guarded by the MethodExpression extension — a named-func-type conversion with a bound method invoked through a func field, values vs Go.)

A GENERIC defined function type — Go 1.23 iter’s type Seq2[K, V any] func(yield func(K, V) bool) — emits a generic delegate: public delegate void Seq2<K, V>(Func<K, V, bool> yield);. Two converter details make this work: the type parameters live on the NAMED type, not the *ast.FuncType’s signature, so the delegate declaration derives its generic definition from the TypeSpec’s defined type (as the struct/array paths do — deriving from the signature emitted a non-generic Seq2 whose K/V were undefined, CS0246/CS0308); and a conversion to a generic instantiation (Seq2Like[string, int](fn)) peels IndexListExpr (multi-parameter — the single-parameter IndexExpr already peeled) and resolves the instantiated target from the Fun expression’s type (the TypeName resolves to the uninstantiated generic, against which convertibility fails), then routes through the same delegate-creation form: new Seq2Like<@string, nint>((@string k, nint v) => …). The instantiated-target override is gated to uninstantiated-generic named targets so pointer conversions ((*uint64)(p), whose Fun type is the full *T with the * re-applied separately) are untouched. (Guarded by the GenericTypeInstantiation extension — a generic defined func type declared, instantiated with two type arguments, and invoked both through a generic function and directly, values vs Go; clears the iter package’s five wave-1 errors.)

In Go a function is a value; a value-receiver method can be assigned to a variable, and the variable captures its own copy of the receiver value at the moment of assignment. This surprises many non-Go programmers:

package main

import "fmt"

type data struct {
    name string
}

func (d data) printName() {
    fmt.Println("Name =", d.name)
}

func main() {
    d := data{name: "James"}
    f1 := d.printName
    f1()
    d.name = "Gretchen"
    f1()
}

This prints Name = James twice (run it) — f1 bound a copy of d, so the later mutation is not observed. To preserve this semantic, the converter copies the receiver value into the delegate’s capture rather than capturing the variable by reference, so the delegate executes against the snapshot taken at assignment time.

A method value reassigned via = hoists its receiver capture

The receiver-snapshot decl above is emitted as a full statement (var dʗ1 = d;) before the lambda. In a := declaration this hoists naturally, but a plain = reassignment to a pre-declared variable — database/sql’s checker = nvc.CheckNamedValue — already wrote the LHS and = operator by the time the snapshot is generated, so writing it inline split the assignment into three token-broken pieces (CS1002). The converter routes the snapshot to the statement hoist buffer so it precedes the whole statement:

var checker func(*driver.NamedValue) error
checker = nvc.CheckNamedValue   // reassign a method value
var nvcʗ1 = nvc;
checker = nvcʗ1.CheckNamedValue;

This matches the :=-define path (which already hoists) and also covers a reassignment inside a tagless switch case. (Guarded by the MethodValueReassignCapture behavioral test.) CheckNamedValue here is an interface method, so the assignment binds a plain method group over the hoisted snapshot (see the interface-receiver rule above); a concrete-receiver method value keeps the param-carrying lambda form, referencing the snapshot the same way.

A POINTER-receiver method value binds the ADDRESS in assignment context too

Go’s sw.Closesocket, where Closesocket has a *Switch receiver and sw is addressable, is (&sw).Closesocket — the address is taken once, at method-value creation (the same spec rule the heap-box arm above rests on). The VALUE-context arm — a method value passed as a call argument — already synthesized that & and bound the method group over the box. The assignment-context arm did not: it rendered the receiver expression plainly and forwarded through the param-carrying lambda, so the lambda body called the [GoRecv] ж<T> extension with a struct value receiver. net’s six socket-hook installs in main_windows_test.go were five CS1929 plus one CS1501, the latter because the value receiver made a different, differently-arity’d overload the compiler’s best candidate:

poll.CloseFunc = sw.Closesocket      // sw is a package-level socktest.Switch
listenFunc     = sw.Listen
poll.CloseFunc = sw.Closesocket;    // was (syscallꓸHandle p1) => sw.Closesocket(p1)
listenFunc     = sw.Listen;

The arm takes the value-context emission wholesale — a method GROUP over the box — rather than only re-pointing the forwarding lambda’s receiver, because two things were wrong, not one:

net’s own sites never showed the second half: sw is package-level, so no capture snapshot is taken. The local-receiver form was broken before this change too — as CS1929 rather than CS0103 — so the guard moved the error rather than introducing it. A receiver expression that is already a pointer keeps both its existing pointer-context rendering and its snapshot: Go copies the pointer there, which is exactly what the snapshot models. (Guarded by MethodValueReassignCapture’s counter.bump arm — a pointer-receiver method value assigned to a pre-declared func var, with the mutation read back through the original local, output-compared vs Go.)

A bare function value in := takes its named delegate type, not var

Go’s short-declaration from a bare function value whose type is a named func type — text/template/parse’s state := lexText, where lexText is func(*lexer) stateFn and type stateFn func(*lexer) stateFn (the classic self-referential state machine) — infers the local as the unnamed signature. The converter cannot emit var state = lexText; (a C# method group has no var-inferable delegate type — CS8917), and typing the local structurally as Func<ж<lexer>, stateFn> makes it a distinct C# delegate from the stateFn the method group produces and that each state = state(l) reassignment yields (CS0029). It declares the local with the matching package named delegate instead:

state := lexText
for state != nil {
    state = state(l)
}
stateFn state = lexText;
while (state != default!) {
    state = state(l);
}

A := from a method group whose signature matches no package named func type keeps the existing path. (Guarded by the NamedFuncTypeStateMachine behavioral test.)

Defer / Panic / Recover

A function that defers or recovers emits its body INLINE, inside a frame

A Go function is a stack frame: it registers defer records in its own frame and runs them on the way out, whatever the way out is. The converted C# says the same thing with statements — the body is emitted inline in the method, wrapped in try/catch/finally, beside a GoFrame local that holds this call’s defer list and nothing else:

internal static void Main() {
    GoFrame  = default;
    try {
        fmt.Println(openFileˢ);
        defer(1 => fmt.Println(1), closeFileˢ, ref );
        fmt.Println(writeDataToFileˢ);
    }
    catch (Exception ex) when (GoFrame.IsPanic(ex, out PanicException? p)) { GoFrame.Capture(p); }
    finally { .Run(); }
}

Each part does one of the three things Go’s runtime does for free:

GoFrame.Run() is the whole of the ordering contract: LIFO drain, the HandledPanic save/restore that keeps a traceback honest for the length of the deferred sequence, the re-panic origin inheritance behind Go’s defer func(){ panic(recover()) }() idiom, and the final re-throw of a panic no deferred call recovered.

Guards: DeferSimple, DeferCallOrder, DeferClosure, PanicRecover, GoexitDefers, DeferFrameScopes, and golib’s GoFrameTests (which A/B every scenario against the machinery this replaced).

Why the body is not a lambda

The obvious alternative models the same three things as an object that owns the body — a func((defer, recover) => …) execution context supplying a catch, a finally and a Stack<Action>. Owning the body forces the body to be a delegate; a delegate forces a display class for everything the body touches; and a display class forces a generic ladder for everything a delegate cannot capture (a ref local, a Span). The frame form needs none of it, and avoids two things beyond the machinery:

Measured with GC.GetAllocatedBytesForCurrentThread over 5,000 Release calls, the frame costs 0 B with no defers, 0 B with one or two whose targets are cached static method groups, and 192 B for the two-capturing-defer shape of internal/poll.FD.Write — the residue being the display class and delegate of each defer that genuinely closes over something. The body-owning alternative measures 160 B, 248 B and 440 B for the same three. The full as-built record of the frame’s design lives in phase4/DESIGN-closure-emission.md.

The named-result form: results outside the try, exits through a label

func f() (r int) { defer func(){ r++ }(); return 1 } returns 2 in Go: the deferred call runs after the result parameter is assigned and before the caller sees it. A C# finally cannot change a value the return has already evaluated, so the results are declared before the try and read back after the finally, and every exit inside the try leaves through a goto — which runs the finally exactly as a return would, without freezing a result the deferred calls may still change:

internal static (nint @out, @string label) compute(nint x) {
    nint @out = default!;
    @string label = default!;
    GoFrame  = default;
    try {
        defer(() => {
            @out += 1000;
        }, ref );
        if (x < 0) {
            (@out, label) = (-1, negˢ);
            goto done;
        }
        (@out, label) = (@double(x), fmt.Sprintf("v=%d"u8, x));
    }
    catch (Exception ex) when (GoFrame.IsPanic(ex, out PanicException? p)) { GoFrame.Capture(p); }
    finally { .Run(); }
    done: return (@out, label);
}

The label is emitted only when something jumps to it — a body that simply falls off the end of the try reaches the return anyway. A heap-box-backed named result keeps the split it already had: the box is declared outside the try and the value alias re-derived inside it, because the deferred closures are still lambdas and a lambda cannot capture a ref local.

Guards: NamedReturnDefer, NamedResultDeferCapture, DeferFrameScopes (which exits from inside a loop, a switch arm and an if).

The catch arm returns Go’s zero results

For a value-returning function whose results are unnamed, the catch arm ends with return default!;. A panic a deferred call recovered leaves the function returning the zero results, which is Go’s rule; one no deferred call recovered never reaches that return at all, because Run() re-throws it from the finally and a throw from a finally overrides a pending return. It is also what keeps the method’s endpoint unreachable, so nothing is needed after the try statement.

recover() is a static call, not a parameter

recover() resolves to builtin.recover(), which reads the one thread-local slot the emitted catch parked the panic in (GoFrame.Capture). That it resolves statically is not an optimization — it is the load-bearing fact of the whole design: a deferred closure can therefore recover without holding any handle on the frame that registered it, which is what allows the frame to be a ref struct in the first place.

One consequence: recover is a Go predeclared identifier, not a keyword, so a package may declare its own — text/template/parse has func (t *Tree) recover(errp *error). Inside that package’s class the extension method wins over the using static go.builtin import, so such a package emits its built-in calls qualified as builtin.recover(), exactly like every other shadowed built-in.

defer registration: the arity ladder, and no bang

Go evaluates a deferred call’s ARGUMENTS at the defer statement and runs the call later, so every argument is captured at registration. defer is an arity ladder of generic rungs (Action and a result-discarding Func twin per arity, 1 through 16, plus a nullary rung) whose last parameter is ref GoFrame — the frame to register into:

defer(fd.writeUnlock, ref );                      //  Go: defer fd.writeUnlock()
defer(1 => fmt.Println(1), closeFileˢ, ref );    //  Go: defer fmt.Println("Close file")

The rungs are generic rather than params object[] so a value argument is captured without boxing on a path that runs on function exit throughout the corpus.

The registration reads as plain defer because nothing else in scope claims that name: defer is a Go keyword, so no Go identifier can ever be spelled that way, and no C# keyword collides. Its siblings carry a ǃ (U+01C3, a legal C# identifier character where ! is not) for reasons of their own: goǃ cannot be go, which is the root namespace every converted file sits in, and makeǃ cannot be make, which is a predeclared Go identifier a package may shadow.

defer panic(v) captures its value at the defer, and the sequence survives it

panic is the one built-in emitted as a throw statement rather than as a call, and that made it the one built-in the deferred-argument machinery could not see. Argument capture happens in exactly one place — the argument-list renderer substitutes a temp parameter (ᴛN) into the thunk body and hands the eager expression to the registration — and the panic arm returns throw panic(<expr>) before reaching it. So the thunk inlined the ORIGINAL expression and the registration’s argument slot was left empty:

defer(1 => throw panic(errΔ2), , ref );   // CS0839: Argument missing

The arm now performs the substitution itself, so the value is evaluated at the defer and thrown from the thunk’s parameter:

err := fmt.Errorf("first")
defer panic(err)
err = fmt.Errorf("second")     // Go recovers "first" — arguments evaluate at the defer
defer(1 => throw panic(1), err, ref );

Capturing the expression in the thunk body instead — dropping the parameter — also compiles, and is wrong for exactly the shape above: it would report whatever the variable held when the frame unwound. The same rule reaches go panic(v); visitGoStmt now forces the temp-param form for a built-in callee just as visitDeferStmt does, which also repairs go close(ch) (a built-in’s method group is generic with in parameters and never converted to Action<T> — CS1503).

Compiling was only half of it. A deferred panic is also the smallest case of a panic raised by a deferred call, and GoFrame.Run treated that as the end of the sequence: the panic escaped the loop and the frame’s remaining deferred calls never ran, so the recover() thunk registered before the panic thunk never saw it. Go continues the sequence — the new panic joins the one unwinding and becomes what a later recover() answers — so Run now parks the raised panic where recover() reads it and keeps going, re-raising it at the end only if nothing recovered it. The catch filter is IsPanic, matching the emitted frame’s own catch, so a runtime fault in a deferred call is recoverable exactly as one in the body is; GoexitException deliberately fails that filter and still unwinds.

Guarded by DeferPanicArg, which output-compares six shapes against go run: a plain value, an error variable reassigned after the defer, a computed expression, a pointer value round-tripping the any boundary and answering a type assertion, a deferred panic replacing one already in flight, and two deferred panics in one frame (Go keeps the LAST one to run, i.e. the FIRST registered).

A nested defer scope gets a frame of its own

A ref struct cannot be captured by a lambda, and it does not need to be: a function literal that defers carries its own frame inside the lambda (or local function) it emits as.

Every frame reads under the same name, at every nesting depth. A C# lambda or local function may declare a local spelled like one in the enclosing method — the pre-C# 8 CS0136 rule does not fire — and the inner declaration precedes every inner use, so an inner GoFrame ᒐ = default; simply shadows the outer one. The one name that cannot repeat is the named-result exit label: labels do not shadow (CS0158, “the label shadows another label by the same name in a contained scope”), so ᒐdone alone carries a nesting-depth suffix. Both facts were settled by compiling the shapes rather than reasoning about them.

That is also what makes a deferred literal that defers on its own account expressible. defer func(){ defer cleanup(); … }() scopes the inner defer to the literal in Go; the literal is a deferred-call target, so it gets no recover scope of its own (its recover() recovers the enclosing function — the whole point of the idiom), but it does get a frame for its own defers, and the inner registration lands there rather than in the enclosing function’s. Guarded by DeferFrameScopes.

An unrecovered panic crashes the process Go-style: report on stderr, exit code 2

throw panic(x) unwinds until some enclosing frame’s deferred sequence recovers it. When nothing does — including in a goroutine, since goǃ runs the body at the root of its own dedicated thread with no frame around it and a frame’s exception filter only adopts panic-convertible exceptions — the exception reaches golib’s AppDomain.UnhandledException backstop (registered in builtin.InitializeGoLib). The backstop matches Go: it writes the report to stderr (panic: <message>, first mapping runtime-error exceptions through RuntimeErrorPanic.TryAsPanic so e.g. an integer divide by zero reports Go’s panic: runtime error: … form) and terminates with exit code 2 — exactly like an unrecovered Go panic, minus the goroutine stack-trace lines. It previously printed to stdout and called Environment.Exit(0), which polluted compared output and signaled false success to every caller (shells, CI, the Phase-4 differential oracle). The behavioral output-comparison harness validates this differentially: the Go binary is the oracle, so exit codes must match (not be zero), stdout must match, and the first stderr line must match — the remainder of Go’s panic stderr is a machine-specific goroutine stack trace, so only the first line is compared. (Guarded by the GoroutinePanicExitCode behavioral test — a goroutine panics unrecovered while main blocks on a channel receive; both binaries must exit 2 with panic: goroutine boom as the first stderr line and a clean stdout.)

An IMPLICIT divide-by-zero panics with the runtime’s OWN value, so it satisfies runtime.Error

Go’s compiler lowers an integer division to a zero check plus runtime.panicdivide(), which panics with runtime.divideError — a value whose dynamic type is the unexported runtime.errorString, and which therefore satisfies the runtime.Error interface. go2cs instead lets the CLR raise DivideByZeroException and maps it to a Go panic at the recover boundary (RuntimeErrorPanic.TryAsPanic), so the panic carried only the message TEXT. That is invisible to code which merely prints the recovered value, but it fails a type assertion — math/bits’ TestDiv32PanicZero asserts exactly that:

} else if e, ok := err.(runtime.Error); !ok || e.Error() != divZeroError {

Div32 divides without an explicit zero guard (unlike Div/Div64, which panic(divideError) themselves), so its panic came from the hardware trap: ok was false, e was nil, and the test’s e.Error() then NRE’d.

golib sits UNDER the converted runtime package and so cannot name divideError, so the dependency is inverted: golib exposes RuntimeErrorPanic.IntegerDivideByZeroValue and the runtime package registers its own canonical value through a [ModuleInitializer] in the hand-owned runtime/panicvalues_impl.cs bridge. An implicit (trapped) divide-by-zero then carries the same value an explicit panicdivide() would — which is precisely Go’s own invariant — and err.(runtime.Error) resolves through the generated errorStringΔError adapter. A converted program that never links runtime keeps the plain-message fallback, which reads and prints identically and loses only the assertion.

(The fallback path is guarded by the DivideByZeroPanic behavioral test, which prints the recovered value; the runtime.Error-typed path is guarded by the committed math/bits Go test suite itself — behavioral tests build against the baseline src/core, which has no runtime package, so the typed form cannot be exercised there.)

make([]T, len[, cap]) out-of-range panics are RECOVERABLE, with Go’s messages

Go’s makeslice panics recoverably for a negative or over-allocatable length/capacity — the recovered value’s text is runtime error: makeslice: len out of range (or cap; probed vs go run — the recovered value is a runtime.errorString). golib’s make path (the slice<T>(nint length, nint capacity, nint low) constructor) raised ArgumentOutOfRangeException/OverflowException for the same inputs — .NET exceptions recover() cannot catch, so a deferred recover never ran and the process died. The constructor now validates first and throws RuntimeErrorPanic.MakeSliceLenOutOfRange() / MakeSliceCapOutOfRange() (recoverable PanicExceptions carrying Go’s message text), using Array.MaxLength as .NET’s maxAlloc equivalent. The same validation class applies to the hand-owned internal/bytealg.MakeNoZero (bytealg_impl.cs) — Go’s runtime implementation of it panics len out of range before allocating, and strings/bytes TestRepeatCatchesOverflow recovers that panic and matches on "out of range" (Phase-4 row R6; strings.Repeat of a near-maxInt product reaches MakeNoZero after passing Repeat’s own overflow pre-checks). Like the established golib runtime-panic convention, the panic STATE is the message string, not an error value — a recovering type switch takes Go’s case error: arm only in Go; both sides converge on the same err.Error() text through the fmt.Errorf("%s", v) default arm. (Guarded by the MakeSlicePanicRange behavioral test — in-range, negative, huge-length, and huge-capacity make under recover(), messages compared vs Go.)

A panicked C# string boxes as Go string at golib’s boxing boundary

Go’s panic takes an any, so the panicked value’s dynamic type is observable on the recover side — if p != "x", err.(string), and case string: all test it. Two converted spellings hand golib a bare C# System.String rather than a Go @string: a string literal (panic("x") — the emission deliberately suppresses the u8 suffix there, so the argument stays a C# literal) and a computed value from a stub that returns C# string (the baseline fmt.Sprintf does). Boxed as System.String, such a value matched nothing on the recover side, which compares against @string: sync’s testOncePanicX reported the self-contradictory want panic x, got x (3 tests).

builtin.panic(object) — the single boxing boundary for the builtin — now normalizes string to @string. Choosing that layer over an emission-site (@string) cast is deliberate: the cast would fix only the literal spelling, while the boundary covers literal, computed, and hand-owned callers alike. It is a narrow normalization, not a coercion — a NAMED string type keeps its own identity (a [GoType("@string")] wrapper is not a C# string), and non-string values are untouched. golib’s own RuntimeErrorPanic values are unaffected: those construct PanicException directly, and Go’s dynamic type for them is a runtime error, not a string. (Guarded by PanicRecover, extended with a recover-side type switch over a literal, a computed, a variable, a named-string-type, an int, and a no-panic case, output-compared vs Go.)

A NIL-POINTER dereference is a RECOVERABLE panic, with Go’s message

The same class as the makeslice arm above, for the most common Go runtime panic of all. Go’s nil dereference is recoverable and real code depends on it — sync’s TestNilPool calls Get/Put on a var p *Pool and asserts that recover() catches the panic. The converted equivalent (reading Ꮡp.Value through a nil ж<T>, or any nil reference deref in emitted code) raises .NET’s NullReferenceException, which recover() could not see: the panic escaped past every deferred recover and surfaced as an unrecoverable host error. RuntimeErrorPanic.TryAsPanic — the single predicate every emitted frame’s exception filter (GoFrame.IsPanic) and the process-level unhandled handler share — now maps it to RuntimeErrorPanic.NilPointerDereference(), whose text is Go’s verbatim runtime error: invalid memory address or nil pointer dereference.

This is faithful rather than lenient: Go recovers a genuine nil-deref bug exactly the same way, and an unrecovered one still prints Go’s message on stderr and exits 2 (the unrecovered-panic arm above). (Guarded by the NilPointerPanic behavioral test — a nil pointer-receiver method call, a nil struct-pointer field read, and a nil map/slice-of-pointer element deref, each under recover() with the recovered text compared vs Go, plus an unrecovered control.)

Named-delegate and builtin callees keep the lambda form

A zero-argument deferred/goroutine’d call whose callee is a named func type (defer cancel() with cancel context.CancelFunc, net dial) cannot take the bare trimmed method-group form — the named type is a DISTINCT C# delegate with no conversion to the Action golib expects (CS1503) — so the invocation stays wrapped: defer(() => cancelʗ1(), ref ᒐ) / goǃ(() => f()). A builtin deferred WITH arguments (defer close(returned)) is generic with in parameters, so its method group neither infers nor converts to Action<T>; the temp-param lambda keeps defer’s eager-argument evaluation: defer(ᴛ1 => builtin.close(ᴛ1), returned, ref ᒐ); (net dial.cs). (Guarded by DeferCallOrder’s stopFn + close(drained) shapes, output-compared vs Go.)

A value-returning goroutine callee is wrapped in a discarding lambda

Go’s go f(…) discards f’s result. Every goǃ runtime overload takes a void Action<…> delegate, so a value-returning callee passed as a bare method group binds no overload (CS0407 “no overload matches the delegate” — x/net/nettest conntest.go’s go chunkedCopy(c2, c2), where chunkedCopy(io.Writer, io.Reader) error returns error). visitGoStmt resolves the callee signature and, when it returns a value, keeps the invocation inside a lambda so the result is discarded — an expression-bodied lambda over a value-returning call converts to Action (the same form the variadic path, e.g. go fmt.Println(…), already emits):

go chunkedCopy(c2, c2)          -> goǃ((1, 2) => chunkedCopy(1, 2), c2, c2);   // param callee
go q.conn.HandshakeContext(ctx) -> goǃ(1 => q.conn.HandshakeContext(1), ctx);      // selector method
go c.Close()                    -> goǃ(() => c.Close());                             // nullary callee

This parallels the defer case. Both defer and goǃ carry seventeen Func<…, TResult> twins alongside their Action rungs, so a value-returning method group with arguments binds either directly; neither has a nullary Func<TResult> rung, so a nullary value-returning callee takes the () => call() discard on both sides (see Deferred calls whose callee returns a value below). The converter’s remaining discarding wraps cover the shapes a method group cannot express here — a value-returning callee reached through a selector or parameter, and the CS1113 value-receiver-extension case below, whose reason is delegate creation rather than the callee’s result. Func-literal callees and void-returning method groups are untouched (goǃ(() => { … }), goǃ(emit, out)).

The runtime’s Func twins are what make one shape expressible at all: a func-literal callee that returns a value. go func(ln Listener) (retErr error) { … }(ln) (net sendfile_test) emits its literal with an explicit error return type, because a named result set by a defer needs one, and no Action<Listener> overload accepts it (CS8934). Wrapping it in the converter would mean suppressing the literal’s own return type and rewriting its trailing return retErr; — rewriting a correct emission to fit a runtime gap. The Func siblings fix that shape, and every other one, at the seam where Go’s rule actually lives: go f(…) discards results, for any f. A void lambda or method group cannot bind Func<TResult> at all, so no existing call site is affected. (Guarded by the GoStmtValueReturn behavioral test — value-returning nullary, single-, multi-param, multi-result and func-literal goroutine callees, output-compared vs Go.)

A VALUE-receiver method callee forces the same lambda forms even when void and arity-matching: every Go named type emits a C# struct and the method an extension on it, and C# forbids constructing a delegate from an extension method over a value-type receiver (CS1113 — net/http/httputil’s go spc.copyToBackend(errc), switchProtocolCopier). So goǃ(spcʗ1.copyToBackend, errc) becomes goǃ(ᴛ1 => spcʗ1.copyToBackend(ᴛ1), errc) and a nullary go vs.ping() keeps its invocation (goǃ(() => vsʗ1.ping())). The receiver snapshot (spcʗ1) still evaluates at go-statement time. An INTERFACE-receiver method group is excluded (a genuine C# instance method binds delegates fine), and pointer receivers keep the box-group machinery (pointerReceiverBoxMethodGroupж<T> is a class, so its group is delegate-legal). (Guarded by GoStmtReceiverLambda’s valueSender arms — value-receiver argument and nullary go-statements with blocking-receive completion proof, output-compared vs Go.)

A func-literal ARGUMENT of a deferred call hoists its captures before the call

When a deferred call’s callee is itself a func literal (defer func() { … }()), that literal’s lambda-capture snapshots (var sʗ1 = s;) are threaded to a builder emitted before the defer(…) call. But when the deferred callee is an ordinary call whose argument is a capturing func literal — x/net/nettest conntest.go’s defer once.Do(func() { stop() }) — the argument literal’s snapshot declarations were dumped inline into the deferred call’s argument list, an invalid statement mid-expression (defer(Ꮡonce.Do, var stopʗ1 = stop; () => …) → CS1001/CS1002/CS1003/CS1026). The hoist sink (lambdaContext.deferredDecls) is now provided unconditionally in visitDeferStmt, not only for the func-literal-callee case, so convFuncLit (reached via convCallExpr → convExprList → the argument’s LambdaContext) routes any argument literal’s captures to it, and they are emitted before the call — the guard’s defer run(func(){ pf.x = 77 }) shape:

var pfʗ1 = pf;
defer(run, () => {
    pfʗ1.Value.x = 77;
}, ref );

The empty builder is inert for a deferred call with no capturing func-literal argument (zero golden churn — the behavioral corpus is byte-identical), and a deferred call whose own arguments are plain captures keeps its existing pre-call generateCaptureDeclarations() emission. (Guarded by the FuncLitArgCapture extension case 14 — a func literal passed as the argument of a deferred run(…) call capturing a local pointer, whose deferred write lands through the shared pointer box, output-compared vs Go.)

Defer/go EAGER arguments follow the enclosing closure’s capture renames

Go evaluates a deferred (or spawned) call’s function value and arguments at statement time, in the enclosing scope. The defer/go emission enters its own lambda-conversion state (its callee snapshots need a fresh remap set), but that fresh state previously hid the ENCLOSING lambda’s capture renames while the eager arguments rendered: inside an IIFE that snapshot-captured a heap-boxed outer local (var baseʗ1 = @base;), the argument of defer func(t Tally) { … }(base) emitted the raw ref-local @base — uncapturable in the IIFE’s C# lambda (CS8175) — with the snapshot left declared but unused; where the raw name IS capturable (a reference-typed local — net/http transport.go’s defer close(didReadResponse) inside a go func() { … } lambda), it silently bypassed the snapshot every other read in that body uses. visitDeferStmt/visitGoStmt now enter through a seeded variant (enterDeferGoLambdaConversion) that copies the enclosing lambda’s renames into the fresh state, so the arguments render exactly like any other expression in the enclosing body:

var baseʗ1 = @base;
((Action)(() => {
    GoFrame  = default;
    try {
        defer((Tally t) => {
            report(deferredˢ, t, 4);
        }, baseʗ1, ref );
    }
    catch (Exception ex) when (GoFrame.IsPanic(ex, out PanicException? p)) { GoFrame.Capture(p); }
    finally { .Run(); }
}))();

prepareStmtCaptures still OVERRIDES the statement’s own captured-callee entries afterward (their defer-time snapshots), and a function-level defer/go — no enclosing lambda — is untouched (the seed set is empty). Whole-stdlib footprint: exactly one file, net/http transport.cs, where the deferred close argument becomes the goroutine lambda’s didReadResponseʗ1 (semantically neutral there — both names alias one channel object; the fix matters for ref-local-boxed value locals). (Guarded by the DeferArgEnclosingCapture behavioral test — a heap-boxed struct local passed eagerly to a deferred func literal, a deferred NAMED callee, and a go-statement literal, each inside an IIFE, with the mutations landing on the deferred copies and the source read back untouched, output-compared vs Go.)

A func-literal ARGUMENT inside an if/for condition hoists its captures before the statement

The same capture-snapshot hazard occurs when a capturing func literal is passed as a call argument inside a condition. go/types is dense with this shape — underIs(t, func(u Type) bool { … }), typeSet().is(func(t *term) bool { … }) — and the literal’s snapshot declarations (var suʗ1 = su;) are statements, invalid inside the condition expression. visitExprStmt / visitAssignStmt already route such decls to a pre-statement hoist buffer (v.hoistedDecls), but visitIfStmt and visitForStmt converted the condition with convExpr(cond, nil) and no hoist target, so the decls were dumped inline into the condition (if (tpar.underIs( var suʗ1 = su; (ΔType u) => { … })) → CS1003/CS1026/CS1002/CS1022/CS1513, ~63 errors across go/types alone). Both statement emitters now convert the condition into a hoist buffer and write any collected decls on their own lines before the if/for, mirroring visitExprStmt:

ΔType su = default!;
var suʗ1 = su;
if (tpar.underIs((ΔType u) => {
    
    if (suʗ1 != default!) { u = match(suʗ1, u);  }
})) {  }

The condition is converted after an if/for init clause (preserving capture-counter ordering), and the if-with-init sub-block hoists between the init and the if. The traditional for reuses the existing ForVarInitMarker slot — the hoisted condition decls are emitted at the same pre-for position as the for-init heap allocations. The hoist buffer is empty for a condition with no capturing func-literal argument, so the behavioral corpus is byte-identical; the only stdlib deltas are five go/types files (under.cs, builtins.cs, expr.cs, index.cs, instantiate.cs) and one crypto/tls slices.ContainsFunc call. This clears the syntax-error layer in those files (go/types had ~63 CS100x/CS1026 from this one construct); it does not by itself green go/types, which compiles far enough afterward to surface a deeper layer of latent semantic defects (a map[token.Token]func() mis-lowered to a malformed explicit-interface IDictionary/ICollection implementation, named-slice wrappers not satisfying IArray.Source, token resolution) — the frontier moves from syntax to semantics, “progress, not regression.” (Guarded by FuncLitCaptureInCondition — a func literal capturing an enclosing map, passed as an argument inside a plain if condition, an if condition with an init clause, a traditional for condition, and a while-style for condition, all output-compared vs Go.)

A func-literal ARGUMENT inside a return expression hoists its captures before the return

The third statement position with the same hazard: a capturing func literal passed as a call argument inside a return expression — net/http’s findHandler returns HandlerFunc(func(w ResponseWriter, r *Request) { … allowedMethods … }), "", nil, nil, and traceviewer’s MainHandler returns http.HandlerFunc(func(){ … views … }). A direct func-literal result threads lambdaContext.deferredDecls (the go/defer/return channel in convFuncLit), but a literal nested as a call argument falls back to the pre-statement hoist sink, which visitReturnStmt never provided — the snapshot declaration was dumped inline inside the return expression (10 syntax errors in server.cs, a 4-error cascade in traceviewer). visitReturnStmt now provides the same hoist buffer as visitExprStmt/visitIfStmt/visitForStmt and splices it before the return through its existing DeferredDeclsMarker slot (ahead of any deferred tuple-deconstruction temps):

var allowedʗ1 = allowed;
return (wrap((@string msg) => {
    fmt.Println(allowedʗ1[0] + ":" + msg, len(allowedʗ1));
}), "label", default!);

The buffer is empty for a return with no capturing-literal argument, so the behavioral corpus is byte-identical. (Guarded by ReturnTupleFuncLitArg — a slice-capturing literal as a call argument inside a three-result return tuple, and a map-capturing one inside a single-result return, output-compared vs Go.)

A func literal inside a RANGE expression hoists its captures before the loop

The fourth statement position, and the one the table-driven test idiom lands on constantly:

for _, test := range []struct {
    desc string
    f    func()
}{
    {desc: "WithCancel(bg)", f: func() { c, cancel := WithCancel(bg); cancel(); <-c.Done() }},
    
} {

The literal’s snapshot declaration (var bgʗ1 = bg;) is a statement, and the composite-literal element position it would be written into is pure expression context. visitRangeStmt converted the range expression with convExpr(rangeStmt.X, nil) — no hoist target — so the decl was dumped inline after the f: argument name, and the whole file died in a syntax cascade (context’s x_test.cs: CS1003/CS1026/CS1002/CS1513/CS0106 ×195, from TestAllocs and TestCause alone).

visitRangeStmt now converts the range expression into a hoist buffer and splices the collected decls in at the statement’s own start — it records that offset before conversion and inserts there afterwards (spliceOutput, the positional twin of replaceMarker), because the foreach header is emitted much further down through a dozen different arms:

    var bgʗ1 = bg;
foreach (var (_, test) in new TestAllocs_type[]{
    new(desc: "WithCancel(bg)"u8, f: () => { var (c, cancel) = WithCancel(bgʗ1);  }),
    
}.slice()) {

The buffer is empty for a range expression with no capturing func literal, so the behavioral corpus is byte-identical. (Guarded by RangeExprFuncLitCapture — slice- and map-capturing literals as struct fields of a ranged composite literal, plus a bare []func(string) element list, output-compared vs Go; its A/B reproduces the cascade exactly.)

The class, stated once: visitExprStmt, visitAssignStmt, visitIfStmt, visitForStmt, visitReturnStmt, visitValueSpec and now visitRangeStmt each provide the pre-statement sink. The statement kinds that still do not — a switch tag, a select comm-clause, a bare send — have no demonstrated corpus site, and each would repeat this failure exactly. They are deliberately not widened speculatively: the tell that one has been reached is a syntax cascade whose first error sits on the line after a <name>: argument label.

The enclosing statement’s hoist buffer does NOT extend into a literal’s BODY

Those four positions all work the same way: the enclosing statement opens a hoist buffer, and a capturing func literal inside it writes its snapshot declarations there. The buffer is a valid position for that literal’s own captures — they name bindings from the enclosing scope, which exists before the statement. It is not a valid position for anything the literal’s body hoists: a statement inside the body opens its own buffer, and a nested literal whose captures name a binding declared inside this body would be declared outside it.

time’s BenchmarkStaggeredTickerLatency nests three levels of b.Run(…, func(b *testing.B){…}). The middle literal makes a stats slice; the innermost go func(…) captures it. The snapshots landed in the OUTER literal’s b.Run(…) statement buffer — two blocks above the declaration:

for (nint tickersPerP = 1; ; tickersPerP++) {
    nint tickerCount = gmp * tickersPerP;
    var statsʗ1 = stats;                      // CS0103 — `stats` is declared below, inside bΔ2
    bΔ1.Run(, (ж<Δtesting.B> bΔ2) => {
        var stats = new slice<>(tickerCount);

convFuncLit now detaches v.hoistedDecls for the duration of the body walk and restores it after, so a nested hoist can only reach a position inside the body. The literal’s own captures are unaffected — they are flushed before the body is converted, while the enclosing buffer is still installed. (Guarded by FuncLitArgCapture case 15.)

Handling Go defer / panic / recover is what the FRAME above is for: the body is emitted inline in try/catch/finally beside a GoFrame local that holds this call’s defer list. panic is the global panic built-in and recover the global recover (both a using static go.builtin). A function that neither directly nor indirectly (through a deferred lambda) uses defer/recover gets no frame at all – the scope is per function, so a main that merely calls f() is emitted as a plain method body.

A BLANK result mixed with a named one still needs the named-return-defer handling

Go permits mixing the blank identifier and real names in one result list — func parse(s string, flags Flags) (_ *Regexp, err error) (regexp/syntax) — and deferred code can still mutate err. The detection required every result to be named and non-blank, so the first _ rejected the whole signature and the function fell back to the unnamed-result form, whose catch arm returns Go’s zero results. On a recovered panic that arm returned default!: the deferred handler assigned err, the catch arm discarded it, and the function reported (nil, nil) — a successful parse of an expression that must fail. Every “expression too large” / “nesting depth exceeded” input (a{100000}, strings.Repeat("(", 1000)+…) came back as a valid parse, and the caller’s dump(re) on the nil pointer then panicked.

A blank result is a real result slot — only a return statement can write it, the body cannot name it — so it needs a declaration alongside the named ones. C#’s _ is the discard: declaring it would capture every later _ = expr in scope, and two blank results would collide outright, so namedResultName mints a generated slot name (interned per result object, so the declaration, each return’s assignment and the post-defer read all agree):

internal static (ж<Regexp>, error err) parse(@string s, Flags flags) {
    ж<Regexp> _1 = default!;          // the BLANK slot
    error err = default!;
    GoFrame  = default;
    try {
        defer(() => {  err = new ΔErrorжerror();  }, ref );   // recover assigns the named result
        
        (_1, err) = (literalRegexp(s, flags), default!); goto done;   // `return literalRegexp(s, flags), nil`
    }
    catch (Exception ex) when (GoFrame.IsPanic(ex, out PanicException? p)) { GoFrame.Capture(p); }
    finally { .Run(); }
    done: return (_1, err);          // reads BOTH slots after the defers ran
}

A result list that is entirely unnamed (func f() (int, error)) or entirely blank keeps the plain form: there is nothing deferred code could mutate, and Go likewise returns the zero results after a recover. Go forbids mixing named and unnamed results, so seeing one truly unnamed result settles the whole signature. (Guarded by the NamedReturnDefer extension — a (_ *box, err error) function whose recover sets err; the pre-fix converter compiles it and returns (nil, nil).)

Function-literal named results

A func literal with named results declares them at the top of its emitted block, zero-initialized — Go’s semantics for next = func() (v1 V, ok1 bool) { …; return } (the iter.Pull shape): a bare return yields the named results as currently assigned, so the lambda emits () => { V v1 = default!; bool ok1 = default!; …; return (v1, ok1); }. Without the declarations the emitted tuple referenced undeclared names (CS0103 — the iter package’s last wave-1 errors). Two interactions: a named-results literal whose first statement is a bare return must NOT collapse to an expression-bodied lambda (the names exist only as block declarations), and the namedReturnDefer path (named results that deferred code mutates) keeps its own arrangement — declarations before the try, returned after the finally. Declarations reuse the shadow-aware naming, so a literal result shadowing an outer local renames consistently in both the declaration and the return (nΔ1). (Guarded by the FuncLitArgCapture extension — bare returns with assigned and zero named results, plus the first-statement-bare-return shape, values vs Go.)

Because a named result lives in the literal’s OWN scope, a reference to it in the body is the result, never an outer-scope capture — so named results are excluded from the lambda-capture set (convFuncLit) exactly as parameters are. text/template’s readFileFS returns func(file string) (name string, b []byte, err error), whose closure captures the enclosing fsys AND writes b via the captured tuple call b, err = fs.ReadFile(fsys, file). Because the closure genuinely captures fsys, the capture analysis ran and mis-flagged b too — hoisting var bʗ1 = b; into the enclosing function, where b does not exist (CS0103), and renaming the body’s b to the captured bʗ1. Filtering the named-result names out of the capture set (alongside the parameter names) leaves b a plain in-block declaration. (Guarded by CrossPkgUser’s makeScanner — a captured closure returning named results, one written via a tuple call whose RHS uses the capture, output-compared vs Go; crypto/x509 and html/template shared the same latent shape.)

Deferred calls whose callee returns a value take the lambda form

The no-arg defer arm passes a bare method group (defer(k.Close, ref ᒐ)) only when the callee returns VOID – an error-returning method (defer k.Close(), registry Key.Close) is a Func<error> method group that cannot bind the golib defer(Action, ref GoFrame) (CS1503). The lambda form discards the result, exactly Go’s deferred-call semantics:

defer(() => hʗ1.close(), ref );

Guarded by DeferTypelessReturns.

Deferred pointer-receiver nullary calls bind the box method group

defer conf.releaseSema() with conf *resolverConfig (net nss.go / dnsclient_unix.go) trimmed to the deref-alias method group Ꮡconf.Value.releaseSema — a struct VALUE against the [GoRecv] ref extension, which cannot create a delegate (CS1113). The emission binds the BOX method group instead:

defer(conf.releaseSema, ref );

The ж<T> overload is class-typed and delegate-legal, and the method-group conversion captures the receiver when the delegate is created — exactly Go’s binding time. Mirrored in the go-statement arm. Gated to methods declared DIRECTLY on the pointee — a PROMOTED method (net interface.go’s defer zc.Unlock(), declared on the embedded sync.RWMutex) has no extension on the outer box (CS1061) and keeps the lambda emission — and to void results (a Func<> group binds neither defer(Action) nor go(Action)). (Guarded by DeferCallOrder’s acquireAndWork, output-compared.)

The same box-method-group emission also covers a value receiver whose type is exactly the pointer-receiver’s pointee — defer b.deck.reset() (runtime/pprof; also database/sql, log/slog), where deck pcDeck is a value FIELD reached through a nested selector and reset has a *pcDeck receiver, so Go auto-takes &b.deck. The original arm required the receiver be an already-pointer ident; the value case renders &receiver through the shared address machinery (the same &ast.UnaryExpr{AND}convUnaryExpr synthesis used elsewhere) — a boxed base gives the aliasing field-ref Ꮡb.of(profileBuilder.Ꮡdeck), an escaping value local gives its box Ꮡx, a plain value gives the Ꮡ(value) copy — then binds the method: defer(Ꮡb.of(profileBuilder.Ꮡdeck).reset, ref ᒐ), the ж overload captured at defer time and mutating the real field. Gated the same way (void result, a NAMED value type whose RecvGenerator box overload exists, matching the pointee exactly so a promoted/embedded method is excluded). (Guarded by the `DeferValueFieldPtrReceiver` behavioral test — a pointer receiver deferring `b.c.reset()` on a value field, and a pointer local deferring the same in a closure, with the reset observed through the same box after return, output-compared vs Go.)

A deferred pointer-receiver method on an escaping value local captures by-box, not by-copy

The emission above binds the box (Ꮡstate.free) for a defer state.free() on a value local — but the CAPTURE analysis must cooperate. defer/go/closure bodies are lambda-conversion scopes: a variable used inside them that escapes to the heap is normally snapshot-copied into a var stateʗ1 = state; declaration so the C# closure captures a value, not an uncapturable ref-local. For an escaping value local used only as the receiver of a pointer-receiver method call (state a handleState value, free a *handleState method — log/slog handler.go’s defer state.free()), that snapshot is doubly wrong: the address-taking is implicit (Go auto-takes &state), so the emission still binds the box — but of the snapshot name Ꮡstateʗ1, which is a plain value with no companion:

ref var state = ref heap<handleState>(out var state);
state = h.ch.newHandleState(buf, true, " "u8);
var stateʗ1 = state;         // snapshot copy — WRONG
defer(stateʗ1.free, ref );        // Ꮡstateʗ1 never declared → CS0103

The capture analysis now recognizes this implicit address-of (a value receiver of a pointer-receiver method, matching the pointee exactly and NAMED — the same guard the emission uses) as a reason to treat the local as a box-ref var, exactly like an explicit &state: it skips the snapshot, and the emission binds the original heap box:

ref var state = ref heap<handleState>(out var state);
state = h.ch.newHandleState(buf, true, " "u8);
defer(state.free, ref );          // binds the live variable's box

This is not merely a compile fix — a value snapshot is taken at defer time, so it would miss any mutation the body makes to state before the deferred call runs; binding Ꮡstate matches Go’s semantics of deferring against the live variable. Gated to an escaping local (a non-escaping one has no box and keeps the compiling Ꮡ(copy) form) used as a value receiver whose type is exactly the method’s pointer-receiver pointee (an already-pointer receiver’s box group is the pointer variable itself, whose snapshot name IS declared, so it is excluded). The same generalization silently corrects the closure form (func(){ x.mutate() } on an escaping value local previously mutated a lost copy — go/types conversions.go/typeset.go) and removes now-dead var xʗ1 = x; snapshots wherever the box was already used. (Guarded by the DeferHeapLocalPtrMethod behavioral test — a value local deferring a pointer-receiver method, mutated after the defer, with the deferred method observing the final value, output-compared vs Go.)

The same box-ref treatment covers a promoted pointer-receiver method reached through value embedslazyCert.Do(…) on var lazyCert struct { sync.Once; v *Certificate } (crypto/x509 AppendCertsFromPEM): Go takes &lazyCert.Once, an address into the variable’s own storage, so the closure must share the original variable. The detection resolves the call through info.Selections and walks the selection’s embedded-field index path — only value embeds along the path root the address at the variable (a pointer embed re-roots it at that pointer’s target, where the snapshot, which copies the pointer, stays sound). Emission then renders the promoted call through the box’s field projection and field uses through the box read; the snapshot form had referenced a never-declared snapshot box (ᏑlazyCertʗ1, CS0103) and divorced the closure’s writes from the original:

lazyCert.of(AppendCertsFromPEM_lazyCert.Once).Do(() => {
    (lazyCert.Value.v, _) = ParseCertificate(certBytesʗ2);
    
});
return (lazyCert.Value.v, default!);

A variable marked box-ref is also never snapshot-copied by a nested literal — the box is a plain reference local that closures at any nesting depth capture directly, so the per-layer var lazyCertʗ2 = lazyCertʗ1; chains disappear with it. (Guarded by the ClosureEmbeddedPromotedPtrMethod behavioral test — an anonymous-struct local with a value embed whose pointer-receiver method is called from sibling and nested closures interleaved with field writes, cumulative counts observed vs Go.)

The same box-ref treatment covers a pointer-receiver method on a value-struct FIELD projection of the escaping local — defer p.fake.setLines() (go/internal/gcimporter iimport.go/ureader.go), where p is an escaping iimporter value local and setLines a *fakeFileSet method on the value field p.fake: Go takes &p.fake, an address INTO p’s own storage, and the emission renders it through the box’s field view — but the snapshot path renamed the base first, referencing a never-declared snapshot box:

var pʗ1 = p;                                  // snapshot copy — WRONG
defer(pʗ1.of(iimporter.fake).setLines, ref );     // Ꮡpʗ1 never declared → CS0103

The capture analysis now matches the single field-projection receiver (a FIELD selected on the var’s own value-struct storage — the same &m.field form lambdaBoxRefAddressForm emits — whose type is exactly the method’s pointer-receiver pointee and NAMED) as the same implicit address-of, marks the local box-ref, and the defer binds the live box’s field view:

defer(p.of(iimporter.fake).setLines, ref );

As with the direct case this is a write-visibility fix, not merely a compile fix: gcimporter registers the defer before importing (which populates p.fake.files), so a snapshot would flush an empty file set. The same generalization corrects deferred closures that read such a variable — go/parser’s defer func(){ …; err = p.errors.Err() }() snapshot-copied p at defer time, so the closure read the parser state from before parsing (errors always empty); box-ref renders those reads Ꮡp.Value.errors… against the live variable. A deeper chain (p.a.b.m()), a pointer field hop, or a method promoted through the field’s own embeds keeps the existing snapshot handling. (Guarded by the DeferHeapFieldPtrMethod behavioral test — a heap-boxed value local deferring a pointer-receiver method on its value field, with lines appended after the defer observed by the deferred flush, output-compared vs Go.)

Expression Switch Statements

Go expression-based switch statements are flexible: cases do not fall through automatically (no break needed), and the fallthrough keyword runs the next case body bypassing its expression. Based on the Manual Tour of Go Conversions, converting to if / else if / else is the best choice for most cases. When every case label is a C# compile-time constant and there is no fallthrough, a traditional C# switch works. “Constant” here means a C# const — a literal, a computed literal expression (a + b), or a typed basic-type const — not merely a Go constant. A case label that references a plain variable, a struct field (case frame.fp), an untyped / named-type / cross-package const emitted as static readonly (case goarch.PtrSize), or an address-of expression (case &g) is not a C# constant, so a C# switch case label there is invalid (CS9135 / CS0150). Such switches fall back to the if / else if form comparing the tag with == (a temp captures the tag: var exprᴛ1 = tag; if (exprᴛ1 == frame.fp) …). The same constant-vs-runtime-value test also chooses is (constant pattern) vs == for a single-value case within the if-else form. A Go break inside a case exits the switch (skipping the rest of the case); in the if / else if form there is no enclosing C# switch for it to target (CS0139), so a case body that contains such a break is wrapped in a do { … } while (false) — the break exits that one-shot loop, i.e. the case. The wrap is emitted only for a case whose body actually has a switch-targeting break (one not caught by a nested loop/switch/select), so every other case is unchanged. (A break inside a nested loop within the case still targets that loop, as in Go.) For cases that use fallthrough, the cases are expanded to standalone if statements with a local fall-through flag and goto to handle break-style exits — the most complex (and least pretty) scenario. In that if-chain form a trailing default: reached via fallthrough is emitted as a guarded if (fallthrough || !match) { … } — the guard is needed so the default does not run after a matched-but-non-fallthrough case, but C# cannot prove it always executes. So when such a guarded-default switch is the last statement of a value-returning function and every case is terminal, C# reports CS0161 (“not all code paths return a value”) even though the Go default makes the switch exhaustive (runtime startpanic_m). Because a guarded-terminal-default switch cannot be legally followed by reachable Go code (it always returns/exits), the converter emits an unreachable return default!; after the if-chain to satisfy C#’s definite-return analysis — gated on the enclosing function/literal actually returning a value (via its own return signature), so a void function or a switch that isn’t terminal is unaffected. (Guarded by the SwitchFallthroughDefaultReturn behavioral test; cleared runtime’s CS0161.) A comparison case may use a C# relational/constant pattern (case {} when x is < 0) only when the compared-to operand is a C# compile-time constant; for a variable (case x == y) or a static readonly const (untyped/cross-package), it falls back to a when guard (case {} when x == y) — a relational pattern there is invalid (CS9135).

A switch on a static readonly constant tag lowers to if-else

A switch TAG that is itself a constant emitted as static readonly – an untyped const’s UntypedInt wrapper (switch goarch.PtrSize, reflect abi.go) or a uintptr-struct const – cannot govern a C# switch: the int case labels are not constants OF the wrapper struct type, and the is constant-pattern lowering fails the same way (CS9135). The recorded tag type is no help (go/types records the untyped constant’s DEFAULT type in tag position), so the gate is on the object resolution: a constant-valued tag that is not a true C# const forces the if-else form (wrapper == operators) and disables the is pattern:

var expr1 = CrossPkgLib.Precision;
if (expr1 == 1) {

A variable tag stays switchable. Guarded by CrossPkgUser / CrossPkgLib.

A leading constant-true case stays opaque to the compiler

Go’s switch { case true: ... case cond: ... } (time parseStrictRFC3339 deliberately disabling its strict checks) compiles the LATER cases as dead code; a foldable when true makes C# reject them outright (CS8120). A constant-true case condition on a NON-LAST clause therefore emits the golib ᐧᐧ marker – a static readonly bool the compiler cannot fold:

case {} when ᐧᐧ: {

The marker is deliberately SEPARATE from the const switch governor: that const’s foldability is itself load-bearing (case ᐧ when ... label patterns need a constant, and an infinite for (...; ᐧ ;...) relies on the fold for reachability proofs – CS9135/CS0161 when it was made readonly in place). Guarded by ExprSwitch.

No constant pattern against a named-numeric wrapper

A constant expression whose CONTEXTUAL type is a wrapper struct – golib uintptr or any [GoType("num:...")] named numeric (time’s Duration) – can never be a C# constant, so no constant/relational pattern can compare against it: d is >= 0 types the literal 0 as Duration (CS9135). The lowering keeps the plain operator form (d >= 0, the wrapper’s operators). Guarded by ExprSwitch (the pace switch).

An index-expression case label falls back to equality

A case label that INDEXES a package-level array/slice variable (case Typ[UntypedNil]: — go/types operand.go, where Typ is the universe *Basic array) is a runtime value, never a C# constant. The single-value is form is doubly broken there: C# parses exprᴛ1 is Typ[UntypedNil] in pattern position as an array TYPE (CS0246 + CS0270). canUsePatternMatch rejects an *ast.IndexExpr label the same way it rejects a non-constant identifier/selector, so the clause takes the ==/AreEqual comparison the multi-value arm already produced:

if (AreEqual(expr1, Typ[UntypedNil])) {   // NOT `exprᴛ1 is Typ[UntypedNil]`

(Guarded by the IndexExprCaseLabel behavioral test — single- and multi-label clauses indexing a package-level array var, output-compared vs Go.)

Literal case labels under a named-type tag compare through a cast

A tagged switch whose tag type is a NAMED (non-interface) type — net/http’s func (code socksReply) String() switching on code — renders the tag as a [GoType] wrapper struct. An untyped-LITERAL label adopts the tag’s named type in Go (go/types records it on the label expression), but its C# render is a bare literal of the UNDERLYING type, which can neither be a constant pattern (exprᴛ1 is 0x01 — CS9135, constant pattern against the wrapper) nor compare bare (exprᴛ1 == 0x01 is ambiguous between the wrapper’s == and the underlying’s built-in ==, both reachable through the wrapper’s two-way implicit operators — the same ambiguity family as the named-string consts). Two converter pieces:

  1. the pattern-match decision excludes any named-wrapper tag (tagIsNamedWrapper, beside the existing namedTypes/tagIsStaticReadonlyConst gates — those could not catch the mixed const-ident + literal switch, because the per-label screening short-circuits once allConst goes false and never reaches its named-type check);
  2. a CONSTANT label that is not an ident/selector/conversion-call (those already render AT the wrapper type) casts to the tag type:
var expr1 = code;
if (expr1 == socksStatusSucceeded) {     // named-const label — no cast
    return "succeeded"u8;
}
if (expr1 == (socksReply)(0x01)) {       // literal label — cast to the tag type
    return "general SOCKS server failure"u8;
}

This also repairs the ALL-literal switch over a named type, which previously emitted the ambiguous bare == form. Full-stdlib footprint: 12 files (socks_bundle, archive/zip, encoding/xml, go/printer, go/types, internal/poll, syscall zsyscall shims). (Guarded by NamedNumericSwitchLiteral — a mixed named-const + literal switch including a multi-label clause, and an all-literal switch, output-compared vs Go.)

A trailing default in a switch WITH fallthrough is guarded on !match

A Go switch with no fallthroughs lowers to a plain if / else if / … / else { default } chain, where the trailing else correctly runs the default only when no case matched. But a fallthrough breaks the chain: the case that fallthrough targets is emitted as a SEPARATE, !match-guarded if (if (fallthrough || !match && <labels>) { … }) so it can be entered both by falling through and by a direct match. A trailing default after such a case was emitted as that if’s bare else — which fires whenever the fallthrough-target if is false, i.e. after any matched NON-fallthrough case, not only when nothing matched. fmt’s printValue is exactly this shape:

switch f.Kind() {
case reflect.Int, : p.fmtInteger()          // a matched non-fallthrough case
case reflect.Pointer:  ; fallthrough
case reflect.Chan, reflect.Func, reflect.UnsafePointer: p.fmtPointer(f, verb)
default: p.unknownType(f)                        // wrongly ran after fmtInteger
}

so formatting an int slice element ran fmtInteger AND then unknownType (→ reflect name resolution → a resolveNameOff stub → panic), breaking %v of every composite. The trailing default is now emitted else if (!match) { /* default: */ } (matchVarName). This is byte-equivalent to the bare else in a pure else-if chain (the default is reached only when !match either way) and correct in the broken chain, so it is a safe general lowering. Like the fallthrough-reached default, the guarded form leaves C# unable to prove exhaustiveness, so a value-returning terminal switch still gets its trailing return default!;. Guarded by SwitchFallthroughDefault (a fallthrough+default switch where a matched non-fallthrough case must NOT run the default, output-compared vs Go).

A NON-TRAILING default is guarded on the PRECOMPUTED any-case-match, never the running flag

Go allows default in any clause position and still picks it only when no case matches — position is presentation, not semantics. The if-chain lowering emits clauses in source order, so a default that is not last is guarded by a predicate that has only seen the arms emitted before it. Every clause after such a default is then dead code, and the default body runs for values those clauses own. Two shapes reach the chain, and the corpus held one live instance of each:

Fallen into. encoding/json’s decode.go array is the witness:

switch v.Kind() {
case reflect.Interface:
	if v.NumMethod() == 0 {  return nil }
	fallthrough
default:
	d.saveError(&UnmarshalTypeError{Value: "array", Type: v.Type(), })
	d.skip()
	return nil
case reflect.Array, reflect.Slice:
	break
}

The default participates in fallthrough, so it cannot be reordered — the fallthrough link is source-order-sensitive. It was emitted if (fallthrough || !matchᴛ1), and for a slice or array target matchᴛ1 was still false (only the Interface arm had run), so the error arm fired and the Array, Slice arm below it was unreachable. json.Unmarshal therefore failed every JSON array whose target was not a bare interface{}json: cannot unmarshal array into Go value of type [1]interface {} for a fixed-size array (which is how net/rpc/jsonrpc passes its [1]any params), and the identical error for every []T. The default now reads the precomputed any-case-match already built for the mirror shape — the OR of every case condition in the switch, materialized ahead of the chain — so it fires only when nothing matches:

var expr1 = v.Kind();
var match1 = false;
var match2 = expr1 == reflect.ΔInterface || (expr1 == reflect.Array || expr1 == reflect.ΔSlice);
if (expr1 == reflect.ΔInterface) { match1 = true;  fallthrough = true; }
if (fallthrough || !match2) { /* default: */  }
if (expr1 == reflect.Array || expr1 == reflect.ΔSlice) { match1 = true;  }

Participating in no fallthrough at all. For a non-constant-label tagged switch the converter already normalized this shape by moving the default clause to the end. A constant-label switch that has fallthroughs elsewhere also lowers to the chain, and that reorder gate did not cover it — so the un-moved default emitted as a bare block, with no condition at all, and ran unconditionally. internal/bisect’s parsePattern leads with default: return …parseError, so every bisect pattern parse returned “invalid pattern syntax” and all six digit arms below it were dead code. (regexp/syntax’s parseEscape has the identical shape and survived only by luck: its default body re-tests !isalnum(c), which happens to exclude every one of its own case labels.)

These are now guarded in placeif (!matchᴛ2) { /* default: */ … } — rather than reordered. Widening the reorder gate was tried and rejected: reordering moves the clause’s statements, but comments attach by source position, so parseEscape’s default-clause commentary migrated up into the octal arm above it. Guarding in place fixes the same defect with no code motion, keeps the emitted C# in Go’s clause order, and costs nothing structurally — emitting an if instead of a bare block is precisely what lets the following clause keep its else, which is the CS8641 problem the reorder exists to avoid in the first place.

Guarded by JsonFixedArrayUnmarshal, which unmarshals JSON arrays into [1]any, [2]int, [3]string, nested [2][2]int and a struct array — including the over-length (truncate) and under-length (zero-fill) cases — and, in the other direction, asserts that the default arm still produces Go’s exact error for the targets it genuinely owns, so the fix cannot be an over-broad one that drops the guard.

A clause’s else may only be dropped when EVERY preceding clause terminates

In the if-chain lowering the converter omits the else before a clause when the preceding clause ended in a return — the chain is then unnecessary, since control cannot reach the later clause anyway. That decision was driven by a shallow “the last statement emitted was a return” flag, which reports only the immediately preceding clause. Dropping the else is sound only when every preceding clause terminates.

For a case the mistake is invisible: its condition cannot match a value an earlier case already matched, so the unchained if simply evaluates to false. For default: it is silently fatal — default has no condition and therefore always runs. os’s (*Process).wait is exactly this shape:

switch s {
case syscall.WAIT_OBJECT_0:      // a bare `break` — falls OUT of the switch
	break
case syscall.WAIT_FAILED:
	return nil, NewSyscallError("WaitForSingleObject", e)
default:
	return nil, errors.New("os: unexpected result from WaitForSingleObject")
}

The break case is not a Go terminating statement, but the returning WAIT_FAILED case set the flag, so the default emitted as an unguarded block that ran straight after the success path:

if (expr2 == syscall.WAIT_OBJECT_0) { do { break; } while (false); }
else if (expr2 == syscall.WAIT_FAILED) {  return; }
{ /* default: */  return errors.New("os: unexpected result from WaitForSingleObject"); }

Every child-process wait therefore failed — and it compiled cleanly the whole time. The decision now uses the accumulated allCasesTerminal check that the trailing-return default!; logic already relies on (genuine Go terminating-statement analysis, so an if { return } with no else is correctly non-terminating). The original condition is kept as one arm of the test, making the change strictly add an else where one was missing and never remove one, so no already-correct emission changes. Guarded by SwitchFallthroughDefaultReturn’s waitShape (non-constant case labels force the if-chain; verified to fail without the fix).

A break in a case that also fallthroughs must skip the fallthrough

A case body containing a Go break is wrapped in do { … } while (false) so the break has a C# target in the if-chain lowering (the case above shows the wrapper). A case ending in fallthrough raises a fallthrough flag the next clause’s guard reads. A case with both put the flag after the wrapper:

if (expr1 == stdISO8601ColonTZ || ) { match1 = true;
    do {
        if (len(value) >= 1 && value[0] == (rune)'Z') { value = value[1..]; z = ΔUTC; break; }
    } while (false);
    fallthrough = true;                    // ← reached by the break as well
}

so the break — which in Go exits the switch — fell through instead. time.Parse is exactly this shape: RFC3339’s Z07:00 layout element consumes a literal Z and breaks, and the fallthrough handed the remaining text to the numeric-offset arm, which rejected it. Go reports extra text: "07:00"; C# reported cannot parse "Z07:00" as "Z07:00", and every Z-terminated RFC3339 value routed through Time.UnmarshalText failed the same way.

Because Go’s spec requires fallthrough to be the final non-empty statement in its clause, “control reached the end of the body” is “the fallthrough statement was reached” — which is what lets the flag be raised at the end rather than at the statement. In a break-wrapped case that equivalence holds only inside the wrapper, so the assignment moves there and a break skips it:

    do {
        if () { ; break; }
        fallthrough = true;
    } while (false);

A case with a fallthrough and no switch-break is unaffected (no wrapper exists), as is a case whose only break belongs to a nested loop — caseBodyHasSwitchBreak already stops at a nested loop/switch/select/closure, so no wrapper is emitted and the flag stays where it was. Guarded by SwitchBreakBeforeFallthrough (the Z-consuming break-then-fallthrough arm, a fallthrough arm with no break, a nested-loop break that must still fall through, and a break with no fallthrough behind it — output-compared vs Go).

Type Switch Statements

For a Go type-switch, C#’s type-pattern switch works well. The runtime exposes the dynamic type via .type(), and the empty interface is any:

func do(i interface{}) {
    switch v := i.(type) {
    case int:
        fmt.Printf("Twice %v is %v\n", v, v*2)
    case string:
        fmt.Printf("%q is %v bytes long\n", v, len(v))
    default:
        fmt.Printf("I don't know about type %T!\n", v)
    }
}

converts to:

internal static void @do(any i) {
    switch (i.type()) {
    case nint v: {
        fmt.Printf("Twice %v is %v\n"u8, v, v * 2);
        break;
    }
    case @string v: {
        fmt.Printf("%q is %v bytes long\n"u8, v, len(v));
        break;
    }
    default: {
        var v = i.type();
        fmt.Printf("I don't know about type %T!\n"u8, v);
        break;
    }}
}

Go int/uint cases and the synthetic concrete case. A Go int maps to C# nint, but an int-valued literal boxed into an interface (do(1)) has C# dynamic type int32, not nint. So a case int: emits its native form plus a synthetic concrete case int32: (and case uint: adds case uint32:) sharing the same body, to catch both boxings. The exception: if the same switch also lists an explicit case int32:/case uint32: (or case rune: ≡ int32), the synthetic is skipped — emitting it would duplicate the explicit case (CS8120 “unreachable case”) and, being emitted first, would steal the explicit case’s values and run the wrong body. With the synthetic suppressed, a typed int value (nint) hits case int: and a typed int32 value hits case int32:, distinctly. (Runtime’s printpanicval switches over int, int8, …, int32, …, uint, …, uint32, …; guarded by the TypeSwitch behavioral test.)

Duplicate-mapped cases — the identical-body merge. Go type distinctions can vanish in the C# type map, making a later case unreachable (CS8120). The canonical example was uint + uintptr under the old System.UIntPtr alias — now moot: uintptr is a distinct golib struct (see Constant Values) and both cases emit their own labels, each dynamic type routing to its own body exactly as in Go. The merge machinery remains for any alias pair that still shares a C# type: a duplicate-mapped case merges only when its Go body is byte-identical to the first occurrence’s — the earlier label already routes both dynamic types to that shared body, so the merge is exact. A marker comment replaces the duplicate label:

case uint64 vΔ1: {
    print(vΔ1);
    break;
}
/* case uintptr vΔ1: merged with an earlier case mapping to the same C# type (identical body) */
case float32 vΔ1: {

If the bodies differ, both labels are kept and the CS8120 stands: a compile error is preferable to silently routing one Go case’s values into another case’s body. Duplicate detection keys on the resolved C# type (uintptrnuint, runeint32, byteuint8) per switch statement; the synthetic int32/uint32 cases register too, so an explicit later duplicate of a synthetic with the same body also merges. Guarded by the TypeSwitch behavioral test (uint + uintptr with identical bodies, values hitting both Go paths).

Multi-type cases stack labels and bind at the tag’s interface type. Go binds a type-switch case variable at the listed CONCRETE type only when the clause lists exactly one type; a multi-type clause (case *Alias, *Named:) binds it at the TAG’s static (interface) type. The old emission split such a clause into one concrete-bound C# case per listed type (duplicating the body), so every body use in an interface-typed context broke — as an argument (isGeneric(t) with t a ж<Alias>, CS1503), an interface assignment (CS0266), and an extension-method receiver (CS1929 — 18 errors in go/types alone). A multi-type clause now emits stacked labels binding only a discard, over one shared body and re-binds the variable to the guard expression — the same re-bind the default arm uses — so the body compiles at the interface type exactly as in Go:

switch (x.typ.type()) {
case ж<Alias> _:
case ж<Named> _: {
    var t = x.typ;          // t: Type (the tag's interface type), as in Go
    if (isGeneric(t)) {  }
    break;
}
case ж<ΔSignature> t: {     // single-type case keeps the concrete binding

The _ designation is load-bearing, not stylistic: it forces the label into PATTERN context. A bare case int8: label resolves the identifier as an EXPRESSION first, where using static go.builtin finds the same-named conversion FUNCTION (int8(…)) — a method group, neither constant nor type — failing CS8917 (encoding/binary’s Size/intDataSize stacks; caught by the census build, not the behavioral corpus, whose labels happened not to collide). case nil stacks as case null:, a dynamic-interface label stacks in its non-binding {} ᴛn when ᴛn._<Iface>(out var _) form, and the synthetic int32/uint32 companions of case int:/case uint: stack with the same discard. The duplicate-mapped-case merge applies per label (a merged label leaves its marker comment above the stack). An UNBOUND multi-type clause (switch x.(type)) stacks the same way with no re-bind — the body is no longer duplicated per label. The re-bind re-evaluates the guard EXPRESSION at body entry — harmless for a pure tag (nothing can mutate it between dispatch and entry), and an IMPURE tag is hoisted into a one-time temporary first (see The type-switch tag evaluates exactly once below). (Guarded by the TypeSwitchMultiCase behavioral test — bound multi-type cases over values and pointers with interface-dispatched body uses, nil stacked with a concrete type, an unbound multi-type clause, and the synthetic-int stacking, output-compared vs Go; also rewrote TypeSwitch’s case int, int64, uint64: golden with output proven unchanged.) Runtime dispatch — .type() unwraps the interface adapters. The case patterns match against whatever object the switch operand .type() returns, so it must surface the Go DYNAMIC value, not the C#-only wrapper classes the runtime uses to carry it. A non-empty interface value created from a Go pointer is a generated IжAdapter wrapping the receiver box (var v shape = &c emits new circleжshape(Ꮡc); see Interfaces), and an interface-to-interface assignment can wrap the source in an IInterfaceAdapter. .type() therefore unwraps — IInterfaceAdapter.Value chains first, then IжAdapter.Box — mirroring the type-assert machinery in _<T>, so a Go case *circle: (emitted case ж<circle> t:) matches the adapter-wrapped value exactly as it matches the raw box that an EMPTY interface (any) holds directly. The bound t IS the original receiver box, so writes through it (t.Value.r += 10) alias the original object, matching Go’s interface-holds-the-pointer semantics; case nil (emitted case null:) still sees the nil interface unchanged. A known edge remains: an interface holding a nil *T (in Go a non-nil interface that matches case *T: binding a nil t) stays wrapped — no C# type pattern can bind a null — so it falls to default rather than wrongly matching case null:. (Guarded by the TypeSwitchPointerAdapter behavioral test — pointer-receiver implementations of a non-empty interface dispatched through single-type, multi-type, no-bind, and write-through cases, plus value-receiver, nil, and raw-box-in-any controls.)

Both unwrap tiers sit behind ONE IGoAdapter probe (2026-07-26). .type() is evaluated once per type switch, and it used to test IInterfaceAdapter and then IжAdapter separately — two failing interface type tests for every ordinary Go value, which is what the overwhelming majority of type switches dispatch on. Measured, that is ~2.9 ns each on the JIT, against a failing sealed- class test too small to measure: a failing interface isinst walks the type’s interface map. The two adapter interfaces now share an empty base marker, IGoAdapter, and .type() probes it once to gate both unwraps, dropping the type switch from 16.0 ns to 4.5 ns per iteration on PerfIface. The unwrap ORDER and results are unchanged; the string@string arm moved after the IжAdapter arm, which cannot change an answer because string is sealed and implements neither marker. See Interfaces for the same gate on the type-assert side and the full measurement.

Named-interface case labels dispatch by method set through the adapter registry. An INTERFACE-typed case label — named (case fmt.Stringer:, case error:) or anonymous — must match by Go METHOD-SET semantics, and after the unwrap above the operand is the raw receiver box, which never nominally implements a C# interface (the generated pointer adapter does). A plain C# type pattern (case Stringer t:) therefore missed every pointer-sourced implementation. All interface labels now emit the when-guard form the anonymous labels already used — case {} Δx when Δx._<Stringer>(out var x): — routing dispatch through golib’s type-assert machinery, which resolves in order: a nominal implementer (value structs made partial, adapter instances, duck-type wrappers) matches directly; a raw box ж<X> (or an adapter asserting to a different interface, via its Box) re-wraps through go.AdapterRegistry — each generated pointer adapter registers (typeof(ж<X>), typeof(Iface)) → box => new XжIface(box) from a [ModuleInitializer], so the lookup is a dictionary hit and a compiled factory, reflection-free and Native-AOT-safe (the initializer also roots the adapter against trimming); an anonymous interface falls back to its runtime duck-typing shell (this said “its generated ᴛAs duck-typing conversion” until 2026-07-25, when anonymous interfaces moved onto the same shells named ones use). The out var x binds at the CASE interface type exactly as Go binds the case variable, the re-wrapped adapter forwards to the original box (writes through the binding alias the original object), label order is preserved (C# tests patterns top-to-bottom, and a when-guarded pattern never makes a later label unreachable), and case nil is unaffected ({} never matches null). The type-assert core is non-throwing (TryTypeAssert), so a non-matching label — the NORMAL control flow in a type switch — costs no exception; this also makes a nil-interface v, ok := x.(T) return ok=false (Go semantics) instead of faulting, and a named interface with no registered adapter is a MISS rather than the former missing-conversion-method hard error. Known residuals, all of the same shape (the adapter type does not exist or its module never loaded, so Go would match where C# misses): a (struct, iface) pair with no conversion site anywhere in the program, a generic struct’s adapter (an open registration key is unrepresentable and a generic class cannot host a module initializer), and FOREIGN value adapters (-composed), which are not yet registered. (Guarded by the TypeSwitchNamedInterfaceCase behavioral test — pointer-adapter value, raw box-in-any, value-struct implementer, case error: in both adapter-carried and raw-box forms, non-matching control, label-order precedence both directions, a multi-type clause of two interface labels, interface-tag-to-interface-label dispatch, and write-through aliasing, output-compared vs Go.)

Struct Types

Go structs are converted to C# struct types and used on the stack to optimize memory use and reduce GC pressure; when an instance must escape the stack it is wrapped in a heap box, ж<T> (see Pointers). Rather than spell out the whole struct body, the converter emits a partial struct carrying a [GoType] attribute, and the TypeGenerator source generator synthesizes the members (equality, ISupportMake, embedding promotion, etc.):

[GoType] partial struct Person {
    public @string Name;
    public nint Age;
}

The generator also chooses the access modifier from the Go name (exported → public, unexported → internal), except where the converter emits an explicit modifier — for instance, an unexported type used as the type of an exported field is published as public to satisfy C# accessibility (the converter emits public partial struct … and the generator honors that explicit modifier).

The synthesized value-equality body compares the struct’s fields against a parameter named other (public bool Equals(Person other) => this.Name == other.Name && this.Age == other.Age;). Each comparison’s left operand is qualified with this. so a Go field whose name happens to collide with the parameter — a field literally named other — still binds field-to-field. Without the qualification a type holder struct { mark int; other int } would emit other == other.other, where the left other resolves to the parameter (a holder) rather than the field (an int), failing to compile with CS0019 (== cannot be applied to holder and int). GetHashCode/ToString reference the same field names but have no colliding parameter, so they need no qualification. (Guarded by the StructFieldNamedOther behavioral test.)

A combined Go field declaration — x, y int — emits a single combined C# line (internal nint x, y;) so the output mirrors the Go source’s line grouping. The combined form is only used when every name in the group shares the same emitted type and access modifier and none needs per-name special handling; otherwise the converter falls back to one line per name. The fallback applies when any of these hold: a blank field _ (renamed per occurrence — _, __, …), a name equal to the enclosing struct type (renamed with the Δ collision marker), a per-field array initializer ( = new(N)), or a mix of exported and unexported names in the same group (X, y intpublic nint X; / internal nint y;). Field comments and tags attach to the whole Go field, so they never diverge within a group.

C# does not allow inline or intra-function type definitions, so these are “lifted” out of the function. A named local type is lifted with its enclosing function’s name as a prefix to avoid collisions — a type x struct{…} declared in main becomes main_x. An anonymous struct (or an anonymous struct used as a field/value) is lifted to a synthesized name with a N suffix and marked dynamic, e.g. [GoType("dyn")] partial struct settingsᴛ1. Struct “definitions” that match structurally remain usable interchangeably (the generator and implicit conversions handle this). A reference to a lifted type as a bare identifier is renamed to the lifted name, and so is its use as a slice or array element type[]entry (where entry is a local type) emits slice<process_entry>, not the short slice<entry> (which is unresolved at package scope → CS0246). The element is resolved through the same lift registry as the bare-identifier and anonymous-struct cases. (Guarded by the LocalTypeSliceElement behavioral test, covering both the slice and fixed-array forms; runtime hit this on printDebugLog’s []readState and traceAdvance’s []untracedG.)

The dyn marker is also the type’s RUN-TIME identity, and it must not leak the synthesized name. An unnamed Go struct has no name to report, so reflect.Type.String() and %T render it STRUCTURALLY — struct { X int; y int }, and struct {} for golib’s EmptyStruct. Because a lift gives the type a synthesized C# name (settingsᴛ1), that name would otherwise be what reflection reports; [GoType("dyn")] is exactly what distinguishes a lift from an ordinary declared struct, whose Go name IS its own. golib’s GoReflect.TypeNaming therefore renders a dyn-marked value type from the GoFields projection — the same field table NumField/Field and the value side read, so a type’s reported name and the fields it hands out cannot disagree — following Go’s format exactly: an embedded field contributes its type alone, a tagged field appends the strconv.Quoted tag. Before it, go/ast’s TestPrint reported ast_internal_test.typeᴛ1 where Go prints struct { X int; y int }, and internal/platform’s decode error named []platform_test.listEntry rather than Go’s structural spelling. (Landed 2026-08-09 with go/ast’s 9/9 bank; see docs/phase4/DESIGN-reflection-bridge.md.)

A map whose VALUE type is an anonymous struct is lifted the same way. A package-level var m = map[K]struct{…}{…} — crypto/internal/hpke’s SupportedKEMs (map[uint16]struct{ curve ecdh.Curve; hash crypto.Hash; nSecret uint16 }) and SupportedAEADs — names its value struct through the lift so the map type reads map<uint16, SupportedKEMsᴛ1>; without it, getAliasQualifiedTypeName’s map arm stringified the value as raw Go struct{…} syntax straight into the C# map signature (map<uint16, struct{ curve ecdh.Curve; … }>) — which C# cannot parse (a CS1519/CS1003 syntax cascade). extractStructType already lifts a slice/array element struct (its ArrayType arm) but has no map arm, so a dedicated extractMapValueStructType lifts the map VALUE struct at the package-level value-spec composite-literal site. The keyed element literals stay the target-typed new(…) constructor form — [0x0020] = new(ecdh.X25519(), crypto.SHA256, 32) — which binds to the lifted struct’s generated constructor; a func-typed value field (SupportedAEADs’ aead func([]byte) (cipher.AEAD, error)) lifts to a Func<…> field that a method-group or func-value element still fills. Both the declaration type (getCSharpTypeName → the map arm) and the literal’s own type render (convMapTypegetExpressionTypeName) resolve the value through the shared liftedTypeMap (the lift runs before the initializer is converted). (Guarded by the MapAnonStructValue behavioral test — a package-level map with an anonymous-struct value type, including a func-typed field, constructed and read back by key, output-compared vs Go.)

The empty struct struct{} is never lifted — it maps to the shared golib EmptyStruct, so a struct{}{} composite literal emits new EmptyStruct() and a map[K]struct{} (“set”) emits map<K, EmptyStruct>. Lifting an empty struct would be doubly wrong: it has no fields to model, and the lift mis-attributes its name and identity. When the struct{}{} is the value assigned to a map element (seen[k] = struct{}{}), the enclosing assignment passes the LHS ident (seen) into the struct-conversion context to name the lift — so the empty struct was being lifted to <func>_seen and registered under seen’s own type, the map map[K]struct{}, in the lifted-type registry. That poisoned every later reference to that map type: the function parameter seen map[K]struct{} rendered as the phantom struct instead of map<K, EmptyStruct>, and its comma-ok deconstruction ((_, ok) = seen[k]) and two-arg indexer vanished (CS8130/CS0021), while real-map call sites mismatched (CS1503). convStructType now short-circuits an empty struct to EmptyStruct before any lift, mirroring the !isEmptyStruct guard that extractStructType already applies everywhere else. (Guarded by the EmptyStructMapSet behavioral test; runtime hit this on typesEqual’s seen map[_typePair]struct{} parameter.)

An empty interface{} field is never lifted either — it maps to any, exactly as a bare interface{} type does. When visitStructType lifts an anonymous struct it walks its fields and lifts any anonymous interface field to its own named [GoType("dyn")] interface. Those three inline lift sites (a plain interface{} field, a *interface{} field, and a []interface{}/[N]interface{} element) type-asserted *ast.InterfaceType directly, diverging from extractInterfaceType — the canonical lift gate, which already excludes empty interfaces. So encoding/json’s slice-encoder cycle memo — ptr := struct{ ptr interface{}; len int }{v.UnsafePointer(), v.Len()} — lifted its ptr interface{} field to a named empty marker interface encode_ptr_ptr. A named empty interface is implemented by nothing, so constructing the struct from the boxed uintptr failed (cannot convert from 'uintptr' to '…encode_ptr_ptr', CS1503). The three sites now carry the same !isEmptyInterface guard, so an empty-interface field falls through to the normal field-type conversion and renders any (*interface{}ж<any>, []interface{}slice<any>). (Guarded by the AnonymousStructs extension cycleMemo — an in-function anonymous struct with an interface{} field constructed from a pointer, read back through the field, and used as a map key, output-compared vs Go; fails CS1503 without the guard. Part of greening encoding/json.)

A returned anonymous-struct composite literal records its implicit conversion AFTER it is lifted. Two structurally-identical anonymous structs are the same Go type but the converter lifts each occurrence to a distinct C# name, so a conversion between them must be bridged by a recorded [assembly: GoImplicitConv<…>] (the ImplicitConvGenerator emits the operators). A closure whose result type is an anonymous struct that returns a composite literal of the identical anonymous struct — mk := func(…) struct{ptr any; len int} { return struct{ptr any; len int}{p, n} } — lifts the closure-result type (…_func_R0) and the composite-literal type (…_type) separately, and visitReturnStmt’s checkForDynamicStructs records the conversion between them. Each side’s C# name is resolved through the per-file lifted-type registry (liftedTypeMap), but a function-local composite literal is only added to that registry during its own convExpr. The recording therefore had to move to run after the result expression is converted: reading the arg’s type earlier found it unlifted and stringified it as raw Go struct{…} text — an invalid C# generic argument in the emitted attribute ([assembly: GoImplicitConv<struct{ptr interface{}; len int}, …_func_R0>], CS1031 “Type expected”). Recording after the lift resolves both sides to their lifted names ([assembly: GoImplicitConv<…_type, …_func_R0>]). The dynamicCast template checkForDynamicStructs may return is applied to the already-converted result expression identically either way, so the reorder is otherwise output-neutral (the full-stdlib A/B reconvert is byte-identical). This is latent for the current stdlib (encoding/json builds its identical memo struct once into a variable, avoiding a second same-shape lift). (Guarded by the ClosureReturnAnonStruct behavioral test.)

Lifted anonymous structs embedding an interface

archive/tar’s ReadFrom-hiding shape — io.Copy(struct{ io.Writer }{tw}, r) — exercises four coupled rules: a SELECTOR embed’s interface check resolves the Sel (Writer), not the package ident (io), so the cross-package interface embed emits as a plain interface FIELD (the promoted-struct property form made the generator construct the interface — CS0144); the composite literal routes the element through the interface conversion at render (interfaceTypes[i], not just the record-only call); a receiver placed into an INTERFACE-typed composite field triggers direct-ж (Go’s interface holds the *T, so the pointer adapter wraps the box Ꮡfr); and the generator emits ONE value-form impl per (struct, interface) pair, folding a Promoted duplicate in (CS0111). ARGUMENT-position values of a mismatched delegate type wrap in the named delegate’s constructor exactly like composite-literal fields (generic delegate params stay native — unsubstituted type params cannot render). Guarded by AnonymousInterfaces (tally/fill, byteRepeat, and the named-array quad/frame Range slice).

The same interface-field record+route also fires for a named non-struct element that implements the field’s interface. The gate above triggered only when the element’s underlying is a struct (or the field is embedded), so a named scalar with a method set — hpack’s DecodingError{InvalidIndexError(idx)}, where type InvalidIndexError int has an Error() method satisfying the error field — recorded no GoImplement<InvalidIndexError, error> and passed the value bare to the interface-typed constructor parameter (which surfaces as NilType, CS1503). The gate now also fires when a named, non-struct, non-interface element type types.Implements the field’s interface — mirroring the call-argument path, which routes any argument into an interface parameter with no struct-only restriction. Recording the GoImplement is what clears the error (the generated implementation makes the scalar implement the interface, so the bare pass then converts implicitly); the render routing (interfaceTypes[eltIndex]) is set too, matching the struct case. An interface-typed element is excluded — it is already the interface and needs no adapter. (Guarded by the InterfaceFieldNamedScalar behavioral test — a named int into an error field and a named string into a local interface field, positional and keyed forms, output-compared vs Go.)

Naming a lift that has no name source — new(struct{ SomeIface }). Every dyn-lift derives its C# type name from context (the declared var, the struct field, the parameter name). A package-level var reserved = new(struct{ types.Type }) (go/internal/gccgoimporter’s singleton) had NO source: the initializer is a CallExpr (the composite-literal up-front lift didn’t fire), so the declaration type fell to the raw t.String() mangle (ж<types.Type}>), and the lift arrived late from the call-argument path under builtin new’s UNNAMED parameter — an EMPTY lift name, declaring partial struct { and registering "" for every reference (@new<>(), [assembly: GoImplement<, …>], new жΔType(…) — a whole-package syntax cascade). Two-part fix: visitValueSpec lifts a new(struct{…}) initializer’s struct UP FRONT under the var’s name (mirroring the composite-literal and hpke map-value lifts), so the declaration, the @new<…> type argument, the GoImplement recordings, and the pointer-adapter names all resolve through liftedTypeMap:

[GoType("dyn")] partial struct reserved1 {
    public global::go.go.types_package.ΔType Type;
}
internal static ж<reserved1> reserved = @new<reserved1>();
p.typeList[n] = new reserved1жΔType(reserved);

and visitStructType itself falls back to the generic "type" when a lift arrives with an empty name (the FUNCTION-LOCAL x := new(struct{…}) form still reaches it through the unnamed-parameter path → main_type), so no caller can produce an unnamed type declaration. (Guarded by the NewAnonStructIfaceEmbed behavioral test — the package-level singleton converted to its embedded interface through the lifted type’s pointer adapter, the embedded field filled and called through the promotion, plus the function-local form — output-compared vs Go.)

An anonymous struct lifts from ANY depth of its declared type

The lift only happens if the converter can find the struct{…} literal in the declaration it is converting, and the probe that found it used to look exactly one level down: through a pointer, or through a slice/array element, or (a later addition) a map value — never through a composition of those. So []struct{…} lifted and []*struct{…} did not, and net’s

var ipStringTests = []*struct {
	in  IP     // see RFC 791 and RFC 4291
	str string // see RFC 791, RFC 4291 and RFC 5952
	byt []byte
	error
}{  }

emitted the raw Go type text into the C# declaration — slice<ж<struct{in net.IP; str string; byt []byte; error}>> — which is not C# at all: CS1031 Type expected, followed by a 90-error syntax cascade that hid every real diagnostic in the file behind it. The shape had been invisible because a composed occurrence still resolved if some other declaration in the package happened to register the identical signature first; error embedded here makes the signature unique, so nothing did.

The probe is now a recursive descent over the type-composing syntax — pointer, array/slice element, ...T, parenthesization, map value then key, channel element — so an anonymous struct (and, by the same helper, an anonymous interface) is found wherever it sits. The AnonStructComposedTypes golden shows the same shape lifting and its elements constructing normally:

[GoType("dyn")] partial struct ptrElems1 {
    internal nint @in;
    internal @string str;
    internal error error;
}
internal static slice<ж<ptrElems1>> ptrElems = new ж<ptrElems1>[]{
    (new ptrElems1(1, "one"u8, default!)),
    (new ptrElems1(2, "two"u8, default!))
}.slice();

This strictly widens what lifts: anything that lifted before still lifts, under the same name. The walk is deliberately first-match, because each lifting caller can name only one anonymous type per declaration — a type expression carrying two distinct anonymous literals (map[struct{…}]struct{…}) lifts the value’s and leaves the key’s. That residual used to apply to every composed shape; it is now confined to that one. The map-value case had its own one-off probe, which the recursion subsumes and which was deleted with it.

Scope, measured by a whole-standard-library A/B reconvert against a converter built from the previous commit — not by a source scan, which got this wrong. A grep for the shape found it only in _test.go files and would have concluded the production corpus was untouched; the A/B found encoding/gob/type.cs, because bootstrapType("_reserved1", (*struct{ r7 int })(nil)) reaches its anonymous struct through a parenthesized pointer conversion(…) then * then the literal — a composition the scan’s pattern never looked for. (Charter §9’s false-alarm rule, from the other direction: a zero-hit scan is only as good as its positive controls, and this one had a control for []*struct{…} and none for (*struct{…}).) The corpus footprint is exactly those seven tReservedN declarations, and the change there is a naming improvement, not a behavior change: the lift now happens at the call argument, where it takes the parameter’s name (eᴛ1eᴛ7), instead of arriving late from convStarExpr’s fallback as the generic Δtype/Δtypeᴛ1…. Declarations, uses and the package_info.cs accessibility block all move together. (Guarded by the AnonStructComposedTypes behavioral test: a slice of pointer-to-anonymous-struct with an embedded error, a map to pointer-to-anonymous-struct, and a slice of slice of anonymous struct, read and written through and output-compared vs Go.)

The struct-FIELD arm was the same probe, and it now shares the same descent. visitStructType kept its own hand-written peel — a chain of field.Type.(*ast.StarExpr) / .(*ast.StructType) / .(*ast.InterfaceType) / .(*ast.ArrayType) arms, each looking exactly one level down — so a struct field declared [N]struct{…} lifted while [N]*struct{…}, []*struct{…}, map[K]struct{…} and chan struct{…} fell through to the same raw-Go-text emission. That the map case had already been patched in as its own arm rather than as a rule is the shape a point-repair leaves behind, and it is what marked this as the next site. The arm now calls extractStructType / extractInterfaceType, the identical helpers every other lift site uses:

type Composed struct {
	Ptrs  [2]*struct{ Size uint32 }
	ByKey map[string]struct{ Count int }
}
[GoType("dyn")] partial struct Composed_Ptrs  { public uint32 Size; }
[GoType("dyn")] partial struct Composed_ByKey { public nint Count; }

[GoType] partial struct Composed {                     // package_info.cs records [GoValueClone("Ptrs")]
    public array<ж<Composed_Ptrs>> Ptrs = new(2);
    public map<@string, Composed_ByKey> ByKey;          // was: map<@string, struct{Count int}>
}

Two properties keep the shared helper faithful to what the arm did before. The lift name stays <struct>_<field>, which is well-defined for every shape because a field type carrying an anonymous literal always names the field — the Go spec makes an embedded field a type name, never a literal. And sub-struct tracking (subStructTypes, which feeds addImplicitSubStructConversions) still records only the two shapes it ever recorded — the field is the anonymous struct, or a pointer straight to it — because that map describes the field’s own declared type; a struct reached through a slice/array/map/channel element is not the field’s type and never was tracked.

Measured by the same whole-standard-library A/B, the widening has no corpus consumer today: no converted package declares a composed anonymous-struct field. What the A/B did change is four files in two packages, all one incidental canonicalization — the shared helpers exclude the empty struct{}/interface{}, and the old field arm did not:

- [GoType("dyn")] partial struct Func_opaque { }          // …and NamedArg__NamedFieldsRequired, Out__…
- [GoType] partial struct Func { internal Func_opaque opaque; }
+ [GoType] partial struct Func { internal EmptyStruct opaque; // unexported field to disallow conversions

Go’s opaque struct{} is struct{}, and golib’s EmptyStruct is what every other site already maps it to — so runtime.Func, database/sql’s NamedArg and Out stop minting a private empty type apiece, three [GoType("dyn")] declarations and their package_info.cs entries disappear, and the Go trailing comment lands back where Go writes it. Nothing referenced the removed names (verified across the whole reconverted corpus, with the baseline emission as the positive control), and the 302-package corpus builds with 0 errors. (Guarded by the AnonStructArrayElement behavioral test, extended: a [2]*struct{…} field, a []*struct{…} field, a map[K]struct{…} field and a map[K]interface{…} field, each read back through its lifted type, alongside the pre-existing one-level [N]struct{…} control and the parenthesized (*struct{ r7 int })(nil) conversion. Its A/B reproduces the defect directly: against the previous binary the three struct fields emit raw struct{…} text. The composed fields are read at their ZERO values on purpose — constructing a value of an anonymous struct type lifts a second, function-scoped name for the same Go type, and a container of it has no implicit conversion to bridge the two. That is the recorded cross-context anonymous-lift identity split, which applies equally to the one-level shape and is a separate increment.)

A global addressed only by the package’s own _test.go is still heap-boxed

A Go pointer to a package-level var aliases that var’s real storage, which in C# means the global must be backed by a heap box (see Pointers); packageAddressedGlobals decides that by scanning the package for &g. But go/packages excludes _test.go from a production package, so an address taken only by the package’s own in-package test half is invisible at the declaration. path/filepath is the canonical case — path.go declares var lstat = os.Lstat // for testing and export_test.go declares var LstatP = &lstat, the whole point being that a test can swap the implementation the production Walk calls. The production emission left lstat a plain field, and the test variant’s Ꮡlstat named a box nothing declared: CS0103.

The converter now scans the build-selected in-package _test.go files for the identifiers they take the address of and folds them into the addressed-global set, so the production declaration carries the box:

internal static ж<Func<@string, (fs.FileInfo, error)>> lstat = new(os.Lstat);
internal static ref Func<@string, (fs.FileInfo, error)> lstat => ref lstat.ValueSlot;  // for testing

Three properties make this the right shape rather than a -tests-only patch:

Only build-selected test files are scanned (go/build’s MatchFile, with the run’s GOOS/ GOARCH and -tags), so the boxed set is a property of the build configuration exactly as the converted production sources themselves are: path_windows_test.go contributes on Windows and path_unix_test.go does not. That is the same rule siblingTestFuncMethodNames already follows, and it is the correct answer — a global no selected file addresses needs no box in that configuration.

Measured across the whole standard library by an A/B reconvert: 13 globals in 13 files, and every single one is a Go “for testing” hook — path/filepath and os’s lstat, os’s testingForceReadDirLstat and allowReadDirFileID, runtime’s readRandomFailed, useAeshash, doubleCheckReadMemStats, casgstatusAlwaysTrack, forcegcperiod and timeBeginPeriodRetValue, reflect’s callGC (whose own comment reads “for testing; see TestCallMethodJump and TestCallArgLive”), internal/poll’s logInitFD, net/http’s maxWriteWaitBeforeConnReuse and testHookEnterRoundTrip, and time’s usPacific. No false positives, which is what the bind-aware exclusion buys — and the same set is forward work, since os, runtime, reflect, net/http, internal/poll and time all need those hooks to alias real storage before their own suites can pass.

External (package foo_test) test files are deliberately not scanned: they reach the package only through its exported surface, and &otherpkg.Var from any other package is a separate, still-open gap — collectAddressedGlobals only ever scans the package under conversion. (Guarded by the SiblingTestAddressedGlobal behavioral test, whose export_test.go addresses a bare global, a global through a field selector, and a global from a function body, against negatives for a test-file-local declarator and a shadowing local. It is the first behavioral project to carry a _test.go; the corpus harness skips _test.go when pairing sources with .cs goldens, since a production transpile never emits one.)

Astral rune literals

A quoted rune literal beyond the BMP ('\U0001D504') cannot be a C# char literal — it emits the code point ((rune)0x1D504); BMP literals keep their source text verbatim (html’s entity table, CS1012 ×133). Guarded by StringConvPostfix (glyphs).

Type-switch default arm binds the interface value

The default clause binds the guard to the ORIGINAL guarded expression (var x = err;), whose static type is the interface — the switch-operand form (err.type()) is object and cannot flow back out (default: return x, go/build/constraint’s pushNot, CS0266).

The type-switch tag evaluates exactly once

Go evaluates the TypeSwitchGuard’s operand exactly once, but the default-arm and multi-type re-binds above textually re-emit the tag expression, so a tag containing a call or channel receive evaluated once at dispatch and again at each matched re-bind arm — switch p := recover().(type) re-called recover() (which returns nil the second time, silently losing the recovered value in a case nil, *bailout:-style arm that reads p; go/types handleBailout), and a switch v := (<-ch).(type) re-received. Such a tag is now HOISTED into a one-time temporary, and both the dispatch operand and every re-bind read it:

var switch1 = next(x);
switch (switch1.type()) {
case @string _:
case bool _: {
    var v = switch1;      // re-bind reads the temp — next() ran exactly once
    
default: {
    var v = switch1;

The hoist is deliberately GATED — only a tag containing a call (conversions hoist conservatively; the temp is merely unneeded) or a receive, and only when some arm actually re-binds (a bound default, or a multi-type clause with a non-blank ident) — so every pure-tag type switch keeps its direct, byte-identical emission. The temp name comes from the per-package getGlobalTempVarName counter (switchᴛN), so nested and sibling hoists never collide. Single-type concrete labels and the when-guard interface labels bind from the dispatch operand’s pattern variable and never re-evaluate the tag regardless. (Guarded by the TypeSwitchImpureTag behavioral test — a counting-function tag whose per-switch eval count is printed and output-compared vs Go [the pre-fix emission provably prints calls: 7 for Go’s calls: 4], a recover() tag in a deferred multi-type switch, and a channel-receive tag that would deadlock on re-receive.)

Generated code global::-qualifies root-namespace references

Inside a package whose namespace nests a same-named segment (go/build/constraint emits into namespace go.go.build), C# binds a generated reference’s leading go RELATIVELY to go.go (CS0234). The generators qualify every type-reference position via GlobalQualify (Common.cs); generated signatures also carry parameter REF KINDS (in slice<byte>) and a canned System.IFormattable impl where the interface inherits it (the hand-finished io stub’s dyn machinery).

The converter faces the same go.go shadowing in the import using directives it emits for a go/* package (go/token lands in namespace go.go, imports sync/unicode sub-namespaces): a rooted using atomic = go.sync.atomic_package; / using go.sync; binds its leading go to the enclosing go.go namespace, resolving go.sync to the nonexistent go.go.sync (CS0234). rootQualifyIfAmbiguous routes its rooting returns through rootQualified, which emits global::go. instead of a bare go. when the package’s namespace second segment is itself go:

using atomic = global::go.sync.atomic_package;
using global::go.sync;

The shadowing is NOT limited to go/* packages themselves: any package with a go/* package anywhere in its transitive import CLOSURE compiles with namespace go.go in scope (its referenced assembly makes go.go a member of namespace go), and C#’s inner-to-outer lookup then binds the bare leading go of a rooted using target to that member from EVERY namespace nested under the root — internal/fuzz (imports go/ast) emitted using bits = go.math.bits_package; inside namespace go.@internal, resolving to the nonexistent go.go.math (CS0234 ×16, plus the same shape in net/rpc’s Δhttp alias and testing/internal/testdeps). rootQualified therefore also emits global::go. when packageChildNamespaces carries the go.go key (populated from the transitive import closure by computeImportAliasRenames’ pre-pass). A package with no go/* anywhere in its closure — every package that was compiling before, and all pre-existing behavioral tests — keeps the bare go. prefix, so there is no golden churn. Cleared go/token, go/doc/comment, go/build/constraint (own-namespace branch); internal/fuzz’s 18 CS0234 and net/rpc’s latent pair (closure branch). Guarded by the GoNamespaceShadow behavioral test, which covers BOTH branches through a nested local module literally named go/nsshadow (emitting namespace go.go, the shape a single-file behavioral test cannot express): the nested lib imports math + math/rand so its own rooted using exercises the own-namespace branch, and the importing main package (namespace go, with go.go in its closure) exercises the closure branch.

Under -tests the shadow gate spans BOTH compilation halves, and the directly-composed using targets must go through it too. The gate had two holes that only a test conversion can expose, and math/rand/v2 (whose regress_test.go imports go/format) hit both — 13 of the package’s 22 compile errors:

  1. The closure was computed per PACKAGE, not per ASSEMBLY. A -tests run recompiles the package’s PRODUCTION sources into the test assembly, so that assembly’s reference closure is the UNION of the production and _test.go closures. The production conversion pass saw only its own half, never learned go.go was in scope, and emitted bare using bits = go.math.bits_package; into a compilation that did contain go.go. collectSiblingTestClosure now runs a metadata-only (NeedName|NeedImports|NeedDeps) load of the test variants before the production conversion and records their transitive import paths in siblingClosureImportPaths, which computeImportAliasRenames folds into the closure it walks — so every consumer of the namespace maps (the shadow gate, rootQualifyIfAmbiguous, isStrippedGoPathPackageRef) describes the assembly rather than the package. The set is empty for every non--tests conversion, so no other output moves.
  2. Targets composed straight from packageNamespace bypassed rootQualified entirely. Both the package-under-test anchor (visitImportSpec’s isPackageUnderTest branch, which REPLACES the rootQualifyIfAmbiguous-derived target with <packageNamespace>.<pkg>_package) and the test host’s using go.testing_runtime; were bare, which is why one emitted file could show a correctly-qualified using iotest = global::go.testing.iotest_package; beside a broken using static go.math.rand.rand_package;. globalQualifyRooted applies the same gate to an ALREADY-rooted path and both sites now route through it. It is idempotent and a no-op with no shadow, so unshadowed packages emit byte-identically.

Both holes fire for ANY package whose test closure reaches a go/* package, and a regress_test.go importing go/format is a common stdlib idiom — this is not a v2 quirk. Guarded by TestGlobalQualifyRootedForcesGlobalUnderRootShadow and TestSiblingClosureContributesRootShadow (src/go2cs/rootShadowQualification_test.go); the behavioral corpus cannot cover them because it never runs -tests and no behavioral package imports a go/* package.

A GoImplement record’s adapter key is canonical, not textual

interfaceImplementations is keyed by RENDERED type name, so one resolved pair recorded under two spellings is two records — and go2cs-gen turns two records into two definitions of the SAME adapter type. The interface side arrives class-relative when PARSED from a package_info.cs (rand_package.Source, via loadPackageImplements) and fully namespace-qualified when rendered at a CAST SITE (go.math.rand.rand_package.Source). canonicalRecordIfaceName stripped only the root prefix, so the two keyed differently, the foreign-adapter existence proof missed, and the pair was re-recorded under the second spelling.

Under -tests this is routine rather than exotic: the EXTERNAL (package <name>_test) variant reaches the package under test through its import path, so it renders that package’s types qualified, while the seeded production metadata carries them short. math/rand/v2 emitted both [assembly: GoImplement<PCG, Source>(Pointer = true)] and [assembly: GoImplement<go.math.rand.rand_package.PCG, go.math.rand.rand_package.Source>(Pointer = true)], and ImplementGenerator’s GetUniqueHintName silently uniquified the duplicate FILE name — so the duplicate TYPE reached the compiler as CS0102 + CS0111 ×5 + CS8646 on rand_package.PCGжSource. (math/rand escapes only by luck: its one self-qualified record targets a different interface than any short record.)

The adapter’s identity is exactly <class>_package.<Type> — the pair ImplementGenerator composes its class name from — so the record key collapses a longer chain to its <pkg>_package tail, leaving a nested type reference (x.y_package.Outer.Inner) untouched. The EMISSION side is normalized to match: stripLocalTypeQualifier rewrites a reference naming one of THIS package’s own types through the package’s fully-qualified class back to the bare local form the attribute file’s using static <ns>.<pkg>_package; resolves, so the two spellings collapse in the emitting HashSet. Guarded by TestStripLocalTypeQualifier.

(Superseded in the details, 2026-08-02: canonicalRecordIfaceName is retired. Both record sets now compose one key through implementRecordKey / canonicalImplementRecordIfaceName — same collapse rule, now shared rather than duplicated. See A foreign implement record is keyed in ONE spelling, and a VALUE one is trusted only for a partial struct.)

The collapse only reaches records the CURRENT run rendered — a stale spelling already on disk slips past it, because package_info_external_test.cs / package_test_info.cs are MERGE-PRESERVING (see the anchor-routing note above). The merge reads each existing attribute line VERBATIM into the emitting HashSet, so a record persisted by an OLDER converter — before stripLocalTypeQualifier reduced it — arrives under the pre-collapse spelling and never meets the fresh, already-collapsed one. container/heap (banked at package #8, before the collapse landed) committed [assembly: GoImplement<IntHeap, go.container.heap_package.Interface>(Pointer = true)]; a fresh -tests run of a NESTED package-under-test now renders that same pair as the bare [assembly: GoImplement<IntHeap, Interface>(Pointer = true)] (the qualified go.container.heap_package. prefix gets rootQualifySubNamespaceTypeRefs-rooted then stripped, whereas a TOP-LEVEL package’s sort_package.Interface is never rooted so it is never stripped and stays byte-stable). The two spellings both survived the merge → GetUniqueHintName uniquified the second .g.cs → a duplicate IntHeapжInterface reached the compiler (CS0102 + CS0111 + CS8646). writePackageInfoFile now runs every merged-in [assembly: GoImplement<…>] line through the SAME qualifyLocalTypeRef pipeline the fresh render applies, so a stale record collapses into the canonical one instead of duplicating it — the whole-line pass is safe because the pipeline only rewrites package-qualified name tokens (bare flag keywords Pointer/Promoted and the assembly/GoImplement scaffolding are untouched), and it is scoped to GoImplement lines specifically so it cannot rewrite a GoImplicitConv attribute’s ValueType = "…" keyword (ValueType is a System-colliding name the rooter would otherwise qualify). Because whole-package conversions (-stdlib, every behavioral test) write with mergeExisting=false they never take this path, so the corpus and behavioral goldens are byte-identical. Guarded by TestMergedStaleGoImplementSpellingCollapses.

A test project’s references cover UNROOTED alias targets (single- AND multi-segment)

A -tests project sets DisableTransitiveProjectReferences, so its references are the direct-import closure plus whatever aliasReferenceImports recovers by scanning the emitted using aliases for namespace tokens. The scan matched only the ROOTED token (go.hash_package), but a SINGLE-SEGMENT package emits its alias UNROOTED — using hash = hash_package; inside namespace go.math.rand, where C#’s outward lookup finds the class in the enclosing root namespace with no qualifier. math/rand/v2’s chacha8_test.cs needs hash purely because sha256.New() RETURNS hash.Hash, so the package appears in no import list and only this scan could have found it: the reference went missing and the build failed CS0246 on hash_package. The scan now also carries a bare token per single-segment package, matched on a SEGMENT boundary (target == token or target starts with token + ".") — a substring test would let hash_package match go.hash.maphash_package and pull in a package nothing references. Guarded by TestAliasReferenceImportsMatchesUnrootedSingleSegmentAlias and TestAliasReferenceImportsDoesNotMatchAcrossSegmentBoundaries.

A MULTI-segment package hits the identical gap when the test’s enclosing namespace SHADOWS the root go. From namespace go.math the alias for os/exec is emitted ROOTED — using exec = go.os.exec_package; (math/rand’s default_test.cs, caught by the HasSuffix(target, token) arm) — but from a namespace whose first segment re-binds go, the alias is emitted UNROOTED and relies on C# outward lookup: go/doc/comment’s std_test.cs (in namespace go.go.doc) and internal/abi’s abi_test.cs (in namespace go.@internal) both emit using exec = os.exec_package;, again purely because testenv.Command(…) RETURNS *exec.Cmd so os/exec appears in no import list. The rooted token (go.os.exec_package) is now ALSO matched when it ends with the unrooted target after a segment boundary — HasSuffix(token, "." + target), so os.exec_package matches go.os.exec_package while the leading . anchor keeps os.exec_package from matching an unrelated go.notos.exec_package. This was the single shared root cause blocking both internal/abi and go/doc/comment (CS0246 on the os namespace). Guarded by TestAliasReferenceImportsMatchesUnrootedMultiSegmentAlias and TestAliasReferenceImportsUnrootedTailAnchoredOnSegmentBoundary.

An emitted CONVERSION RECORD names packages no import list and no alias mentions. A using alias is not the only line in the test metadata that must BIND: go2cs-gen realizes every [assembly: GoImplement<…>] / [assembly: GoImplicitConv<…>] record into a generated adapter, partial or operator, so both generic arguments have to resolve at the attribute itself. The converter records an interface pair from a type’s use, and that use can be entirely implicit — os/signal’s test does cmd.Stdout = &buf, whose os/exec field type is io.Writer, so package_test_info.cs carries

[assembly: GoImplement<strings_package.Builder, io_package.Writer>(Pointer = true)]

while io appears in no import list of the production package or its tests, and in no alias. Under DisableTransitiveProjectReferences that is CS0246 on io_package at the attribute line, plus a cascading go2cs-gen CS8785 (ImplementGenerator failed … second generic type argument must be an interface) once the unbound interface degrades to an error type — the generator then contributes nothing and the whole package’s adapters vanish. The scan therefore also reads the record lines, extracting each type reference’s package-class qualifier — everything up to and including the first segment ending in _package (io_package, go.io.fs_package, go.@internal.abi_package). That is deliberately the qualifier, not the whole type reference: it has exactly the shape a using alias TARGET has, so the same three token-match arms above decide both, with no second matcher to keep in sync. Only the record’s generic argument list is scanned (first < to last >, so a nested ж<…> argument is covered whole) — an attribute’s (Pointer = true) / (ValueType = "…") payload is metadata, and the ValueType is a string, not a reference. Additive as before, and the manifest’s dependency list stays import-derived. This is what lets os/signal validate (its TestCtrlBreak, 1/1 vs go test). Guarded by TestAliasReferenceImportsMatchesConversionRecordPackages, TestAliasReferenceImportsMatchesConversionRecordQualifierShapes and TestAliasReferenceImportsIgnoresConversionRecordAttributePayload.

Referencing a go/*-package TYPE loses a root segment because the path’s own go collides with the root namespace. A go/ast type reference renders correctly as go.go.ast_package.X (root go + the path’s go.ast → namespace go.go, class ast_package), but convertToCSTypeName then strips the leading go. as a redundant root (bodies live inside namespace go), leaving go.ast_package.X — namespace go, which has no ast_package (CS0234/CS0426 in the go/* consumers go/doc, go/printer, go/internal/typeparams, whose GoImplement attributes and using aliases both carry the stripped form). The two rooting helpers now recognise this: isStrippedGoPathPackageRef splits the ref at its first _package class segment and tests the namespace portion against packageChildNamespaces (the current package’s rooted import-closure namespaces): the ref is stripped iff that namespace is NOT already a real rooted namespace but becomes one when the root go. is prepended. This is a membership test, not a string-shape test, so it recognises a stripped go/-package ref at any depth — go.ast_package (ns go✗ → go.go✓), go.build.constraint_package (ns go.build✗ → go.go.build✓, three-segment go/build/constraint), go.doc.comment_package (ns go.doc✗ → go.go.doc✓) — while leaving a genuinely-rooted ref alone (go.io.fs_package — ns go.io is already real). (The earlier two-segment string heuristic — “the class segment sits immediately after go.” — recognised only the depth-one go.ast_package shape and silently missed the three-segment go/build/constraint and go/doc/comment sub-package refs, which are string-indistinguishable from a correctly-rooted go.io.fs_package; the membership test is what disambiguates them.) rootQualifySubNamespaceTypeRefs (the assembly-scope GoImplement/GoImplicitConv attributes) re-roots the stripped form to a bare go.go.ast_package; rootQualifyIfAmbiguous (the in-namespace using aliases) re-roots to global::go.go.ast_package — always global::, because a bare go.go.<pkg>_package re-binds its leading go to the nearest enclosing go from *any importer (a go/-package’s own go.go.* namespace, and equally internal/pkgbits at go.internal.pkgbits resolving the second go inside go.go, CS0234). This un-blocks the whole go/ chain at the rooting level (go/doc’s own-errors 17 → 1); each go/* package still needs its remaining per-package residuals (e.g. a methodless-func-type’s [GoTypeAlias] still names an inline-rendered ΔFilter) to fully compile. The depth-one shape is now guarded by GoNamespaceShadow (its go/nsshadow nested module’s import renders through isStrippedGoPathPackageRefusing nsshadow = global::go.go.nsshadow_package;); the multi-segment sub-package depth (go/build/constraint) remains census-verified only — the A/B reconvert-diff showed only the four go/build/constraint- and go/doc/comment-importing packages, go/build, go/doc, go/parser, go/printer, gaining the corrected double-go rooting, with the depth-one go.go.ast_package refs unchanged and zero collateral.

BCL names in generator templates are global::-qualified too — a Go type can shadow any bare BCL name. The generated partials sit inside the package class, where every Go type in the package is a sibling member that wins name lookup over System.*: internal/trace/traceviewer declares type Range struct, so the named-string wrapper’s sub-slice indexer this[Range range] bound the Go Range instead of System.Range (CS1503 inside its own ViewType.g.cs). This is a class of collisions, not one bug — any package declaring a type named Range, Index, Type, Span, … is exposed — so the audit qualified every BCL reference the TypeGenerator templates emit: global::System.Range (string/slice/array indexers), global::System.Span<T>/ReadOnlySpan<byte>, the IEnumerator/IEnumerable members, ICloneable, IEquatable and the System.Numerics operator interfaces on numeric wrappers, System.Type/Reflection.MethodInfo/Activator/ NotImplementedException/[DebuggerNonUserCode] in the dynamic-interface machinery, and the GeneratedCode attribute stamped on every generated declaration (Common.cs, shared by all generators). golib names (slice<T>, NilType, IChannel, …) stay bare — they live in the go namespace the generated code owns. Converter-emitted visible code is not part of this rule (it renders BCL names by the file-scoped conventions above). (Guarded by BclTypeNameShadow — a package declaring type Range struct alongside a named string type and a named slice type, both sub-sliced with the Go Range’s fields as bounds, output vs Go.)

Generic embedded fields

A GENERIC embed (entry[K,V] embedding node[K,V], internal/concurrent) arrives in the AST as an IndexExpr/IndexListExpr over the base type; the anonymous-field walk unwraps it (plain, pointer, and selector forms) and the member emits under the base name with type arguments stripped before the selector dot-strip — the arguments may contain qualified types whose dots otherwise win the LastIndex (*concurrent.HashTrieMap[T, weak.Pointer[T]] misnamed its member Pointer instead of HashTrieMap). The TypeGenerator’s promoted accessors carry the type parameters on the instance param (ref Δentry<K, V> instance) and strip them from the member access (instance.node.isEntry). A promoted method call through a raw ж box local hops X.Value ahead of the cross-package pointer-embed hop (m.Value.HashTrieMap.Value.Load(value), unique). BANKED: unqualified promoted METHOD calls through a generic embed (w.show()) — receiver wrappers resolve the embedded type by exact name; qualified calls work. Guarded by GenericStructFields (wrapped[T]/tag[T]) and CrossPkgUser (holder[T] embedding *CrossPkgLib.Cache[T]).

A func literal in an any slot states its Go result type explicitly

A function literal converted into a real empty-interface parameter has no delegate target type, so C# natural-types it from its return arms — func(x int) int { return 0 } inferred Func<nint, int> (the literal 0 is C# int, i.e. Go int32), and the natural type becomes the value’s runtime dynamic type, which reflection then classifies: func(int) int and func(int) int32 collapsed to ONE managed type, so quick.CheckEqual saw equal func types where Go’s differ (testing/quick’s TestFailure #3). The emission states the declared Go result type explicitly:

CheckEqual(func(x int) int { return 0 }, func(x int) int32 { return 0 }, nil)
CheckEqual(nint (nint x) => 0, int (nint x) => 0, default!);

Scoped to single-result literals in any slots (CallExprContext.emptyInterfaceArgsLambdaContext.untypedInterfaceTarget → convFuncLit’s explicit-return-type mechanism); target-typed positions are untouched — their delegate supplies the type, and an explicit return type there could only add identity-match constraints against hand-written stub delegate types. Multi-result any-slot literals keep natural tuple typing (no demonstrated consumer). Guarded by the LiftedLocalTypes behavioral test; operationally by testing/quick’s banked suite.

Lifted function-local types: anonymous structs dedupe, named types carry [GoLocalName]

C# forbids type declarations in method bodies, so the converter lifts function-local types to package scope under a function-prefixed name. Two Go type-identity rules ride the lift:

// package_info.cs [GoLocalName(“Person”)] public partial struct TestNoFixedSize_Person {}

Guarded by the `LiftedLocalTypes` behavioral test (single lifted declaration for repeated
anonymous occurrences + `[GoLocalName]` pinned in the golden); operationally by
encoding/binary's banked suite.

### A methodless named func type renders as its base delegate

Go treats a named func type as freely interconvertible with its underlying `func(...)` when the
type has **no methods** — the name is purely documentary. `type releaseConn func(error)`
(database/sql) and `type CancelFunc func()` (context) are assigned to and from anonymous
`func(...)` values without conversion: `grabConn` returns `releaseConn`, `queryDC` takes
`func(error)`, and Go passes one to the other. Emitting the named type as a *distinct* C#
delegate (`ΔreleaseConn`) broke this — the base `Action<error>` its underlying renders to has no
implicit conversion to it (CS1503/CS0029), and the mismatch even excluded the `ж`-receiver
overload of methods taking such a param, so `db.pingDC(...)` on a boxed `*DB` failed with CS1929.

A **non-generic** named func type with **no methods** is therefore rendered AS its base C#
delegate (`Action`/`Func<…>`) everywhere it is referenced (`getAliasQualifiedTypeName`/`getFullyQualifiedTypeName` return
the underlying signature), and its declaration is skipped (`visitFuncType` emits only a marker
comment). Every named↔underlying conversion becomes identity, exactly as Go models it:
```csharp
// type releaseConn is a methodless func type — rendered inline as its base delegate
internal static (ж<driverConn>, Action<error>, error) grabConn(this ж<ΔConn> Ꮡc, context.Context _) { … }
internal static error queryDC(this ref DB db, …, Action<error> release, …) { … }

Three exclusions keep the collapse sound — a type is left as a named delegate if any holds:

Because the collapse applies at both the declaration and every reference, and to foreign types too (context’s CancelFunc collapses in context’s own conversion, so database/sql sees Action), consistency holds across packages. One position needed a companion fix: a variadic ...Option element is package-class-qualified (main_package.Option) for a package-local named type, which would mangle a collapsed delegate to main_package.Action (CS0426) — variadicElementType now skips the qualifier when the element collapsed. Cleared 13 of database/sql’s 17 errors (the whole named-func family + the CS1929 it masked). Guarded by MethodlessFuncType (a function returning the named type, one taking the anonymous underlying, a struct field, and a tuple-deconstruction seam across the two); regression-checked against the self-referential (NamedFuncTypeStateMachine, unchanged), nested-reference (FirstClassFunctions), and variadic-param (PublicizedFuncTypeParam) cases.

A collapsed methodless func type must NOT export a [GoTypeAlias]. When such a type is also collision-renamed — type Filter func(...) alongside a method Filter (go/ast’s Filter vs (CommentMap).Filter; the ReservedTypeMethodCollision shape) — the rename records an exported [assembly: GoTypeAlias("Filter", "ΔFilter")] so consumers can name the renamed type. But because the type collapses to its base delegate, no <pkg>_package.ΔFilter type is ever emitted — so a consumer that loads the alias generates global using astꓸFilter = go.go.ast_package.ΔFilter; naming a nonexistent type (go/doc referencing ast.Filter, CS0426). visitFuncType now records each collapsed methodless func type’s name in packageInlineFuncTypeNames, and the exported-type- alias emission skips any alias whose key or value matches (the collision path stores the alias under the renamed value ΔFilter, the plain path under the raw name) — so the alias is never exported and the consumer renders ast.Filter inline as Func<nint, bool> through the normal collapse. (Guarded by the CrossPkgUser extension — a cross-package CrossPkgLib.Sift methodless func type colliding with a Sift method, named as a var type and rendered inline, output vs Go; and by ReservedTypeMethodCollision whose [GoTypeAlias] is now correctly absent.)

When such a collapsed delegate’s signature carries a parameter whose type lives in a sub-package (an import path with a slash), the Func<…>/Action<…> rendering must qualify that type as the package class, not the namespace. The collapsed signature is produced from the Go signature’s t.String(), which keeps the canonical import PATH inline — func(*sync/atomic.Int32) int32, func(string, io/fs.DirEntry, error) error (path/filepath’s WalkDirFunc) — losing the file’s import alias. convertToCSFullTypeName converted the whole slash-bearing string as one import path, dotting the type straight into the namespace: sync.atomic.Int32 / io.fs.DirEntry — CS0234, since atomic is not a namespace of go.sync (the type lives in class atomic_package). It now splits the trailing .TypeName off at the first . after the last path /, converts the package path with the class suffix, and re-appends: sync.atomic_package.Int32, io.fs_package.DirEntry. The suffix is only added when the path segment does not already carry it — some callers (a recorded [GoType] underlying, sync/atomic_package.Uint32) hand a pre-suffixed path, which would otherwise double to atomic_package_package (a DefinedTypeOverPkgType regression, caught and gated). The behavioral corpus is byte-identical except the intended change, and an A/B reconvert of net+go/types (same package set) is byte-identical — only the func-type-subpackage-param shape moves. (Guarded by the SubpackageFuncTypeParam behavioral test — a methodless applyFunc func(*atomic.Int32) int32 whose collapsed delegate carries the sync/atomic sub-package parameter, output-compared vs Go; the same shape drives path/filepath’s WalkDir/Walk referencing io/fs.DirEntry/FileInfo.)

A collapsed func type’s parameter list must not be double-converted. convertToCSFullTypeName’s func( handler split the parameter string with extractTypes, then re-ran convertToCSTypeName over each result — but extractTypes already renders a NAMED parameter in C# form (it strips the Go name and converts the type). Re-feeding an already-C# map<@string, ж<Object>> through the map< arm’s splitMapKeyValue mis-parsed it into map<@string, ж<Object>, > — a spurious trailing empty type arg (CS1031 “Type expected”, go/ast’s NewPackage taking type Importer func(imports map[string]*Object, path string) (…)). The fix makes extractTypes always return C#-form (the bare-type/unnamed branch now converts in place too, matching the named branch), and the caller trusts that output directly instead of a second pass. This is byte-identical everywhere except named-parameter func types — bare-type func types (func(int, string)) were already converted once and stay so, just at the extractTypes site rather than the caller. (Guarded by the NamedFuncTypeMapParam behavioral test — type Importer func(imports map[string]*Node, path string) (pkg *Node, err error) used as a function parameter, output-compared vs Go; CNR byte-identical across the corpus, and an A/B reconvert of go/ast shows only that one collapsed-delegate parameter shape moving.)

A collapsed methodless named func type’s DELEGATE TYPE renders through iifeDelegateType, not the string path. The double-conversion fix above kept the collapsed delegate on convertToCSFullTypeName’s func( string handler — but that string domain naively slash→dots a cross-package element’s import PATH. go/doc passes simpleImporter to ast.NewPackage (whose importer is ast.Importer, a methodless func type), so the converter wraps the method group in the collapsed base delegate new Func<map<@string, ж<go.ast.Object>>, @string, (ж<go.ast.Object> pkg, error err)>(simpleImporter)go/ast.Object mangled to go.ast.Object (no _package class, no file alias), so ast is not a namespace of go (CS0234 ×2), and the resulting error-typed delegate then fails the method-group→delegate conversion (CS0123). getCSharpTypeName now routes a methodless named func type through the SAME structural iifeDelegateType path an ANONYMOUS signature already takes (that path exists precisely because the string path mangles slash-bearing package paths), naming each element via aliasedElementTypeName — so the cross-package ast.Object keeps its ast alias (and a Δ-renamed foreign element its recorded -alias): new Func<map<@string, ж<ast.Object>>, @string, (ж<ast.Object>, error)>(simpleImporter). The only visible change for a SAME-package/single-segment element is that a multi-result signature’s delegate type drops its Go result-tuple element NAMES ((ж<Node> pkg, error err)(ж<Node>, error)), matching how anonymous signatures already render — cosmetic, both compile. An A/B full-stdlib reconvert moves 11 files, all equal-or-better: the go/doc mangle fixed, plus go/parser/go/scanner (go.token_package.ΔPositiontokenꓸPosition), go/internal/gccgoimporter (a malformed (io.ReadCloser>, error) → valid), internal/trace/traceviewer (net.http_package.Requesthttp.Request), and path/filepath (io.fs_package.DirEntryfs.DirEntry) all cleaned up, with bufio/go/ast/nettest only dropping cosmetic tuple names; CNR touches only three existing goldens (NamedFuncTypeMapParam, SubpackageFuncTypeParam, FirstClassFunctions), all the same pattern. (Guarded by the CrossPkgUser extension — a package-level simpleResolve passed as a METHOD GROUP to CrossPkgLib.Resolve, whose Resolver is a methodless func type naming the cross-package *CrossPkgLib.Node, so the wrapped delegate renders ж<CrossPkgLib.Node> via the alias, output-compared vs Go. The single-segment producer compiles either way, so the byte-golden — unnamed vs Go-named result tuple — is what guards the routing; the exact slash-bearing CS0234/CS0123 needs a multi-segment producer like go/ast, verified by the go/doc source A/B. go/doc’s own remaining block is the SHARED generated-adapter forwarding of go/ast’s unexported interface marker methods — a separate root.)

A companion root cleared path/filepath fully: a cross-package type ALIAS whose target lives in yet another packageos.FileInfo = fs.FileInfo (os/types.go, target in io/fs) — is emitted as an assembly-scoped global using FileInfo = go.io.fs_package.FileInfo; in os’s own conversion, never as a member of the os package’s C# class, so a cross-package reference os_package.FileInfo does not resolve (CS0426, path/filepath’s lstat = os.Lstat func value). getAliasQualifiedTypeName now renders such an alias as its targetos.FileInfofs.FileInfo (→ io.fs_package.FileInfo via the file’s fs using). Gated to a different-package target: an alias to a SAME-package type (CrossPkgLib.Temperature = Celsius) already resolves through the existing global-using alias (CrossPkgLibꓸTemperature) and is left untouched — narrowing here reverted a CrossPkgUser churn the blanket form caused. CNR byte-identical; an A/B of os+io/ioutil (same package set) shows only that one intended resolution (io/ioutil’s ReadDir sort lambda moved osꓸFileInfofs.FileInfo, matching the file’s other fs.FileInfo refs — still compiles). GUARD OWED — the shape needs three packages (B declares Y, A aliases type X = B.Y, C references A.X), which neither the single-package baseline nor the 2-package CrossPkg harness expresses; validated by the core/path/filepath build (1→0) + io/ioutil build.)

A func type renders structurally in EVERY type-name path — the signature never stringifies. getAliasQualifiedTypeName now carries a *types.Signature arm (signatureTypeName, beside iifeDelegateType) mirroring the slice/map/chan composite arms: Go syntax — func(name type, …) results — with every parameter/result type resolved recursively, so a cross-package element keeps the file’s short import alias exactly like the neighboring map/slice fields. Previously only some positions routed through the structural iifeDelegateType (var declarations; variadic or slash-bearing struct fields); every other position — a struct field of a named methodless func type (go/importer’s importer gccgoimporter.Importer), a MAP field’s func value type (net/http’s TLSNextProto maps), a same-package named func field (traceviewer’s f MutatorUtilFunc) — reached convertToCSFullTypeName as t.String() text with import PATHS inline, and the slash heuristics mangled those one of three ways depending on the string’s shape: the whole-string path-conversion arm fires when no dot-after-slash precedes the first [ (a leading map[ bracket), naively dotting every path — ж<go.types.Package>, ж<crypto.tls.Conn> (no _package class, and under a go.go-nested namespace the leading segment binds the child namespace — CS0234) — while the split-at-dot arm mangles a mid-signature path to a classed-but-unrooted form (@internal.trace_package.UtilFlags, traceviewer mmu.cs). With the structural arm the string reaching the parser is slash-free (func(*Server, *tls.Conn, Handler)) and each element converts through the normal alias route. Result NAMES are preserved, so a named multi-result field keeps its named C# tuple (the display-path advantage the old struct-field routing existed to protect); a same-package/builtin signature renders byte-identically to the old t.String() path (zero churn — CNR confirmed across all 331 behavioral projects). A variadic tail renders ...elem (which the old path’s ..-strip reduced to the unparseable .elem) and lowers through the parser to the golib ꓸꓸꓸ delegate family (next paragraph). One side effect: element recursion passes through getAliasQualifiedTypeName’s foreign-ALIAS arm, so a signature naming a cross-package alias (os.FileInfoio/fs.FileInfo) now registers the target’s package for a file-local using — a few stdlib files gain a benign using fs = …; alias line (collectTypePackages’ Named case does not match a *types.Alias, so the old path never registered it). Whole-stdlib A/B footprint: 20 files — the go/importer field fixed, ж<tls.Conn> in net/http server/transport/h2_bundle (field + composite literals), traceviewer’s Func<trace.UtilFlags, (slice<slice<trace.MutatorUtil>>, error)>, go/scanner’s err field moving to the canonical tokenꓸPosition alias (the old go.token_package.ΔPosition resolved only by go.go-namespace luck), the variadic type-assert target below, one comment-alignment shift, and the benign using-line additions. Cleared the IMP-2/HTTP-3 CS0234 cluster (net/http ×8 + go/importer ×6 + traceviewer). (Guarded by the SynthesizedDelegateChildPkg behavioral test — a nested CHILD subpackage (slash-bearing import path) whose *inner.Record rides a named methodless func-type field with a nested-tuple lookup param AND a map[string]func(*inner.Record, string) field, both invoked at runtime vs Go.)

The func-type string parser splits parameters at TOP-LEVEL commas only — and a variadic tail lowers to the ꓸꓸꓸ delegate family. extractTypes split the parameter list with a naive strings.Split(signature, ","), so a nested func param returning a TUPLE — lookup func(string) (io.ReadCloser, error) (go/internal/gccgoimporter’s Importer, surfacing as go/importer’s gccgoimports.importer field) — shredded at the tuple’s interior comma, unbalancing the assembled delegate: Func<@string, (io.ReadCloser>, error) (the inner > closes before the tuple’s second element — a 6-error syntax cascade, IMP-1). splitTopLevelParams tracks <>/()/[]/{} depth (with the channel-arrow <- guard splitMapKeyValue already carries) and splits only at depth 0. On top of that, a variadic tail (...elem, from the structural render above) converts its ELEMENT type in extractTypes and carries an ellipsis-family marker that the func( assembler hoists into the delegate FAMILY name — Actionꓸꓸꓸ<@string, any> — mirroring iifeDelegateType’s lowering exactly. That fixed the variadic func type as a type-ASSERTION target as a rider: .(func(string, ...any)) (net/http transport.go’s tLogKey logger) previously emitted the unparseable ._<Action<@string, .any>>(ᐧ) and now renders ._<Actionꓸꓸꓸ<@string, any>>(ᐧ). (Guarded by the FuncFieldNestedTupleParam behavioral test — builtin-typed struct fields with nested-func-returning-tuple params in both the anonymous and named-collapse forms plus a named-tuple-result sibling, all invoked at runtime vs Go.)

A type ASSERTION whose target is a methodless func type must assert against the collapsed delegate, not the (never-emitted) name. ci.(Compressor) where type Compressor func(io.Writer) (io.WriteCloser, error) (archive/zip’s compressor/decompressor registries) rendered ci._<Compressor>()convTypeAssertExpr converts the target via convExpr, which emits the bare ident, and after collapse Compressor is undefined (CS0246). When the asserted target is a methodless named func type, the assertion now renders its getCSharpTypeName (the collapsed Func<…>): ci._<Func<io.Writer, (io.WriteCloser, error)>>() — matching how the stored value was emitted (a collapsed delegate). Other assertion targets are unchanged. (Guarded by the MethodlessFuncTypeAssert behavioral test — i.(Compressor) on a matching and a non-matching dynamic type, output-compared vs Go; CNR byte-identical and an A/B of archive/zip shows only the two intended _<Compressor>/_<Decompressor>_<Func<…>> lines.)

An UNINITIALIZED local var of a methodless named func type renders its declared type through the same structural path. visitValueSpec’s no-initializer branch computed the type from convertToCSTypeName(getAliasQualifiedTypeName(...)) (the string path) and only re-routed a bare anonymous *types.Signature through getCSharpTypeName; a methodless NAMED func type is a *types.Named, so it kept the string render — and that render mangles a slash-bearing cross-package element. go/parser’s parseDecl declares var f parseSpecFunction (type parseSpecFunction func(doc *ast.CommentGroup, keyword token.Token, iota int) ast.Spec), which emitted Func<ж<go.ast.CommentGroup>, go.token.Token, nint, go.ast_package.Spec> f = default!; — the go.ast/go.token elements re-root to the nonexistent go.go.ast/go.go.token (CS0234), and the declared delegate then mismatched the lambdas assigned to f and the parseGenDecl(keyword, f) parameter, which render the SAME Go types structurally as ast.CommentGroup/token.Token (CS1661/CS1678/CS1503 — 12 errors, all this one declaration). The no-initializer branch now routes a func-typed var (anonymous signature OR methodless named func, via methodlessNamedFuncSignature) through getCSharpTypeNameiifeDelegateType, whose aliasedElementTypeName keeps each element’s pkg.Type alias: Func<ж<ast.CommentGroup>, token.Token, nint, ast.Spec> f = default!;. This precedence matches getCSharpTypeName’s own — the func render wins over the foreign-alias route (which for a methodless named func would point at the SKIPPED delegate declaration); a non-func foreign-renamed local keeps its alias unchanged. An A/B full-stdlib reconvert moves exactly one file (go/parser/parser.cs), greening go.parser outright. (Guarded by the MethodlessFuncType extension — an uninitialized var find lookup where type lookup func(string) (path string, ok bool); the byte-golden captures the structural render Func<@string, (@string, bool)> — dropping the Go result NAMES the string path keeps — output-compared vs Go. As with the delegate-routing sibling above, a single-segment/same-package producer compiles either way, so the unnamed-vs-Go-named result tuple is what guards the routing; the exact slash-bearing CS0234 needs a multi-segment producer like go/ast, verified by the go/parser source A/B.)

Named delegate types wrap mismatched initializers

A NAMED func-type field initialized with a value of a DIFFERENT delegate type has no implicit C# conversion: internal/concurrent’s keyHash: mapType.Hasher feeds a hashFunc field from a Func<…> field. The composite-literal walk resolves each element’s field BY NAME (keyed-aware) and wraps mismatched delegate values in the target delegate’s constructor — keyHash: new hashFunc((~mapType).Hasher) (the wrap splits a C# named-argument label first). FuncLit and nil initializers stay bare. Guarded by FirstClassFunctions (handler/provider/registry).

A named delegate value passed to a structural func parameter re-wraps

The MIRROR of the argument-position named-delegate wrap: a structural (written-anonymous) func parameter receiving a value of a named delegate type — net/http h2_bundle’s sc.scheduleHandler(…, handler), where handler is HandlerFunc and the parameter is func(ResponseWriter, *Request) (CS1503). Go converts named→structural implicitly; C# needs the same delegate re-wrap, targeting the synthesized structural delegate:

type Handler func(int, string) string   // has a method → distinct C# delegate
func invoke(f func(int, string) string, n int, s string) string { return f(n, s) }
var h Handler = describe
invoke(h, 1, "a")
invoke(new Func<nint, @string, @string>(h), 1, "a"u8);

Two argument shapes render named and take the wrap: a value whose Go type is a named func type (with methods), and a := local declared from a method group, which the declaration emission types with the matching package named delegate (HandlerFunc handler = Ꮡsc.Value.handler.ServeHTTP; — the bare-function-value := rule above) even though go/types keeps it structural — the exact h2_bundle shape. A methodless named func type already renders as the structural delegate (methodlessNamedFuncSignature collapses it — same C# type), so it stays bare; method groups and func literals themselves convert natively. A generic structural parameter (unsubstituted type params) also stays native. (Guarded by the NamedDelegateStructuralParam behavioral test — named-with-method and method-group-declared locals wrapped, methodless/method-group/func-literal controls bare, values vs Go.)

The same mirror applies to a composite-literal FIELD (2026-07-17; sort’s test-suite conversion): the composite walk previously wrapped only the named-field ← different-delegate direction, so GOROOT sort example_keys_test’s planetSorter{planets: planets, by: by} — a By value (named, with a Sort method) initializing the written structural field by func(p1, p2 *Planet) bool — emitted the bare by: by against the Func<ж<Planet>, ж<Planet>, bool> constructor parameter (CS1503; the Phase-4 blocker-map row B10b). The structural-field arm now applies the identical named-rendering test and wrap: by: new Func<ж<Planet>, ж<Planet>, bool>(by). Method groups, func literals, and nil stay bare, and generic fields stay native, as at call sites. (Guarded by the NamedFuncTypeStructuralField behavioral test — the By-with-method sorter pattern wrapped, a method-group field initializer control bare, values vs Go.)

Func-typed fields with a cross-package (slash-path) type render structurally

A func-typed struct field whose signature names a type from a multi-segment import path — testing/quick’s Config.Values func([]reflect.Value, *rand.Rand), where rand is math/rand — must render as a structural Action/Func<…> delegate via getCSharpTypeName, not through the string display path. The display path stringifies the signature as func([]reflect.Value, *math/rand.Rand) and splits the slash-bearing import path on /, emitting the dotted math.rand.Rand; but math aliases to math_package, so math.rand resolves to the nonexistent math_package.rand (CS0426). The structural renderer recurses per signature element and qualifies each named type by its package name:

type Config struct {
    Values func([]reflect.Value, *rand.Rand)   // rand is math/rand
}
public Action<slice<reflectValue>, ж<rand.Rand>> Values;

The re-routing is gated on the signature string containing / or the signature being variadic: the string path cannot render a variadic signature at all — getAliasQualifiedTypeName’s .. strip reduces the ellipsis of go/build’s JoinPath func(elem ...string) string (Context, build.go:84) to .string, emitting the unparseable Func<.@string, @string> (CS1031 + CS1003 ×2, all three go.build errors), and even unstripped it has no variadic lowering. Structurally such a field renders the golib variadic delegate family (public Funcꓸꓸꓸ<@string, @string> JoinPath; — see the variadic-lowering section below), which loose-arg, empty and spread calls through the field all bind. Every other func field keeps the display path: func(string) (importPath string, ok bool) preserves its named tuple elements that the structural renderer drops. (Guarded by the FuncTypeParam behavioral test’s runner.gen field, and by VariadicFuncFields — a struct with variadic func-typed fields assigned from a named func and func literals, called loose/empty/spread — for the variadic arm.)

A variadic func type lowers to the golib Actionꓸꓸꓸ/Funcꓸꓸꓸ delegates

A variadic function TYPE used as a value — a parameter, variable, struct field, or collapsed methodless named type such as go/types’ reportf func(format string, args ...interface{}) — used to have three mutually incompatible lowerings: the delegate type rendered Action<@string, slice<any>> (no params — the BCL Action cannot express one), a variadic func LITERAL emitted the named-function convention (@string format, params ꓸꓸꓸany argsʗp) => … (CS1661/CS1678 against that Action), and calls through the value passed loose Go-style args as if params existed (reportf("…"u8, (~f).typ) — CS1503; reportf("empty type set"u8) — CS7036).

The lowering now targets a golib delegate family carrying a real C# 13 params Span<T> tail (src/core/golib/variadic.cs; fixed-arity prefixes up to eight mirror the BCL Action/Func family, and the ꓸꓸꓸ suffix reads as Go’s ...):

public delegate void Actionꓸꓸꓸ<T1, TArg>(T1 arg1, params Span<TArg> args);
public delegate TResult Funcꓸꓸꓸ<T1, TArg, out TResult>(T1 arg1, params Span<TArg> args);

iifeDelegateType — the single structural lowering every getCSharpTypeName(*types.Signature) and collapsed methodless named func type routes through — names the family when sig.Variadic() and passes the variadic element type as the last type argument. Everything else then agrees with zero changes to the other emissions, because the parameter types match by identity (ꓸꓸꓸT is Span<T>):

A :=-declared variadic func literal is untouched: it keeps C#’s natural (params-capable) lambda type under var (the VariadicClosureSpread shape). One deliberate residue: defer/goǃ of a call through a variadic func value would need to capture the Span tail, which a ref struct cannot be — no stdlib occurrence; pack into a slice at such a site if one ever appears. Full-stdlib A/B footprint: go/types predicates.cs/expr.cs plus every file that renders a variadic func type structurally (inspected file-by-file at introduction). (Guarded by VariadicFuncValues — a named func AND a func literal satisfying a variadic func-typed param, loose/empty/spread calls through it, and a nil-compared variadic func-typed var — output-compared vs Go.)

A type-ASSERTION target routes through the same structural lowering. convTypeAssertExpr rendered the asserted type by converting the TYPE EXPRESSION through the string-based type-name path, which skips the variadic lowering above — net/http transport.go’s cw.(func(string, ...any)) emitted ._<Action<@string, .any>>(ᐧ) with a literal .any (CS1001, the ... mangled instead of lowered). An anonymous-signature assert target now renders through getCSharpTypeNameiifeDelegateType, exactly like the collapsed methodless NAMED func target already did: ._<Actionꓸꓸꓸ<@string, any>>(ᐧ). Non-variadic signatures render identically on both paths, so the only full-stdlib delta is the transport.cs site. (Guarded by VariadicFuncTypeAssert — a positive variadic assert invoked through the asserted value, a negative assert on a non-func value, and a non-variadic anonymous func assert, output-compared vs Go.)

Major-version import directories

A /vN import path segment (math/rand/v2) hosts a package named for the PARENT segment, so the emitted class follows the package NAME: consumers reference go.math.rand.rand_package, and the namespace is go.math.rand — never the path-derived v2_package / go.math.rand.v2. Go’s own convention (the directory is a version marker, not the package identifier) means the package name equals the second-to-last path segment, and every place the converter derives a class/namespace/ alias from a /vN import path must honor it. There are four such derivations, reached by different renderers, and each needed the convention applied at its own site:

  1. using-alias + namespace emissionconvertImportPathToNamespace (visitImportSpec.go) rewrites the last path part to the parent segment via majorVersionSegmentRegex, so the file’s using rand = go.math.rand.rand_package; and the package’s own namespace go.math.rand agree.
  2. t.String()-based FQ type renderinggetAliasQualifiedTypeName / getFullyQualifiedTypeName (main.go) build a foreign type’s name from the type graph’s path-qualified string, whose last segment slash-strip assumes the path tail IS the package qualifier. For a /vN tail it left the version behind (math/rand/v2.Randv2.Rand), which the alias-prepend then doubled into rand.v2.Rand (v2 read as a member of class rand_package — CS0426). Both renderers now reduce the foreign import-PATH qualifier to the package NAME before the slash-strip. getFullyQualifiedTypeName also composes pkg.Path()+"_package" directly for the qualified base name — routed through packageClassPath, which swaps a /vN tail for the Go package name.
  3. Cross-package reference metadataPackageInfo.RootPackageName (importOperations.go) is the code-facing qualifier that keys imported-alias loading and the foreign-implement records that cast sites reference (GoImplement<…rand_package.PCG, …rand_package.Source>(Pointer = true)). It was the path’s last segment (v2); rootPackageNameFromPathParts now returns the parent segment for a /vN tail. PackageName stays path-formed — it also names the referenced .csproj, which IS math.rand.v2.csproj.
  4. Imported type-alias TARGET classloadImportedTypeAliases (importOperations.go) qualifies an imported alias’s target as go.<PackageName>_package.<Type>; the class path is PackageName with its final segment replaced by RootPackageName, so a /vN producer’s exported aliases resolve to rand_package, not v2_package.

The convention is that a package literally named vN would instead need the type-graph name; the stdlib has none, so the regex/parent-segment rule holds corpus-wide. Guarded by the VersionedImport behavioral test — a main importing a sibling vlib/v2 module (package vlib) that mirrors math/rand/v2’s shape: a struct field ж<vlib.Rand> (renderer #2), a *PCG → Source pointer cast recorded as go.vlib.vlib_package.PCG (#3/#4), output-compared vs go run across all four phases. This is what unblocks sort as Phase 4’s second validated package (its test suite imports math/rand/v2).

A C# keyword inside a dotted import-path element

A Go import-path element may itself contain dots — a module host (gopkg.in, example.com, golang.org) or a versioned tail (yaml.v3) — and every one of those dots is a namespace separator in the emitted C#. So gopkg.in/yaml.v3 does not render two namespace levels, it renders four, and each is a separate C# identifier that has to be keyword-escaped on its own.

Two sanitizers render namespace text, and until 2026-08-07 only one of them knew that. The declaration side (getProjectNamegetCoreSanitizedIdentifier) has always split an element on its dots, so the dependency’s own file correctly opens namespace go.gopkg.@in;. Every consumer emission — the import’s using yaml = …; alias, the enclosing-namespace using gopkg.…; an unaliased import adds, the child-namespace map that decides root qualification, and the string-path type renderer — goes through convertImportPathToNamespace, which sanitized each /-split element with getSanitizedImport, measuring it whole. Whole, gopkg.in is not a C# keyword, so it passed through bare and the importer emitted

using yaml = gopkg.in.yaml_package;   // CS1001/CS1002/CS1022 — `in` is a keyword
using gopkg.in;

against a producer that had named itself go.gopkg.@in. The dependency compiled; nothing that imported it could. (Issue #33: gopkg.in/yaml.v3 converts, then does not build.)

The fix is one function, both sides: getSanitizedImport splits on dots too, escaping each level independently — exactly what the declaration side does. The recursion stays inside getSanitizedImport rather than deferring to getCoreSanitizedIdentifier, because callers append the _package class suffix to the final element before calling and the core sanitizer Δ-prefixes anything ending in _package; that swap would emit Δyaml_package, a class no producer declares. Escaping is idempotent (an already-@-marked part returns unchanged), so re-sanitizing a rendered namespace is stable.

The behavior change is exactly “a dotted input with a keyword sub-token is now escaped”: a string containing a dot could never equal a keyword, so the old whole-string test never fired for one, and hyphen/tilde replacement is per-part identical either way. Emission-neutral for both corpora, and measured so — the behavioral corpus has no dotted module path at all, and the standard library’s only dotted element is golang.org (the GOROOT-vendored tree), whose golang/org are not keywords: CNR byte-identical across 572 packages, and a seeded full reconvert byte-identical across 5,179 .cs/.csproj/README.md plus the generated go2cs-stdlib.slnx.

Guarded at both altitudes: TestGetSanitizedImportKeywordSegments (sanitization_test.go — several keywords in several positions, the two sanitizers asserted to agree on a segment, the _package suffix asserted NOT to be Δ-prefixed, and idempotence) and TestRecurseKeywordNamespaceSegment (moduleConverter_integration_test.go — network-free, an unaliased import of a gopkg.in-shaped dependency, asserting the producer’s declaration and both consumer emissions name the same namespace, then sweeping every using in the file against the converter’s own keywords set so a keyword the fixture never exercises is covered by the same assertion). Both neuter-proven: restoring the whole-string measurement reproduces the reporter’s emitted text verbatim.

The import-path rewrite rewrites only the PATH, not the constructor in front of it

The string-path type renderer (convertToCSFullTypeName) peels a Go type expression one constructor at a time — <-chan , chan , chan<- , *, [], [N], map[K], func(…) — recursing on what is left. Its import-path rewrite runs first, before any of those branches, because a package-qualified element has to become a C# namespace before the name means anything. Until 2026-08-08 that rewrite measured the path from index 0 of the whole string, constructor included.

convertImportPathToNamespace maps a hyphen to an underscore, because a Go path element may legally contain one (mongo-driver, go-isatty). Handed the constructor as well, it rewrote the - of <-chan too. The declaration in go.mongodb.org/mongo-driver/x/mongo/driver/session

type Pool struct { descChan <-chan description.Topology }

renders fully-qualified as <-chan go.mongodb.org/mongo_driver/mongo/description_package.Topology, and came back as <_chan go.mongodb.org.mongo_driver.…. No channel branch recognizes <_chan, so it fell through to the array branch, which slices past the > that a [N] length closes — and with no > in the string at all, strings.Index returned -1 and the slice was typeName[0:]. The renderer re-entered on the IDENTICAL string, without bound: fatal error: stack overflow, taking a 1,726-package -recurse run down at package 1456 (issue #33’s third report).

A single-segment path never had a slash to enter the rewrite, so <-chan time_package.Time was always correct. That is the whole reason the standard library — which is nothing but single- and multi-segment stdlib paths, none of them hyphenated behind a <-chan — never saw this, and only a module dependency could.

The fix is importPathStart: find where the path begins by scanning backward from the candidate region to the first byte no import path may contain, and rewrite only from there. - cannot be that delimiter (it would split mongo-driver mid-path), but every constructor the renderer emits ends in one that can — a space (<-chan , chan , chan<- ), *, ] ([], [2], map[K]) or ( (func(). <-chan then survives for its own branch, which recurses on the bare qualified element exactly as it always has, and descChan emits /*<-*/channel<go.mongodb.org.mongo_driver.mongo.description_package.Topology> against the using description = global::go.go.mongodb.org.….description_package; the same file writes.

Two subtleties the first cut got wrong, both caught by CNR:

Known residual. When the OUTERMOST constructor is [], its leading [ is read as the start of a generic argument list and truncates the path scan to nothing, so []<-chan <module path>.T is still mangled. Fixing it means no longer treating a leading [ as a generic bracket, which re-routes every []<pkg>/<sub>.T in the corpus through the other branch (a _package-suffix change) — a corpus-wide emission change that does not belong in a crash fix. It no longer crashes, which is the part that mattered: see below.

The crash-proofing is separate from the rendering fix, and is the part that generalizes. The array branch now requires the > it slices past. A Go stack overflow is a fatal runtime error, not a panic, so the conversion driver’s per-file recover could not contain it and one unrenderable type killed the whole run instead of its own package. Bounded, an unrecognized shape is reported by name on stderr (Cannot render a C# type name for the unrecognized type expression "…") and the package still converts. Every other branch consumes at least one byte before recursing, so bounding this one bounds the renderer.

Guarded at both altitudes. typeNameResolution_test.go pins the renderer: TestImportPathStart (each constructor, the marker runes, and the paths that must NOT move), TestConvertToCSFullTypeNameConstructedModulePaths (the reported field in all three channel directions, */[]/[N]/map[K]/nested, plus the single-segment and bare-path cases that must stay byte-identical, plus the residual above pinned as a decision), and TestUnclosedBracketTerminates, which runs in a child process with a 4 MB stack because the condition it guards is unrecoverable in-process. TestRecurseChannelOfHyphenatedModulePath (moduleConverter_integration_test.go) pins that a real declaration of the reported shape reaches that renderer through an actual -recurse, over a network-free fixture whose module path mirrors the report’s — hyphenated first segment, multi-segment tail. Its fixture carries a type alias to the channel alongside the struct field, deliberately: a field DECLARATION emits the readable file-local alias (description.Topology), so the fully-qualified render is computed but never written and a test reading only the field cannot tell a correct render from a mangled one — while an exported alias writes the fully-qualified string verbatim into both main.cs and the [GoTypeAlias] record.

Neuter-proven three ways: both reverted reproduces the reported fatal error: stack overflow; the bound alone reverted fails the child test in 0.02 s; the rewrite alone reverted (bound in place) makes the converter print the warning, exit 0, and still write every .cs — the crash-proofing demonstrated independently of the rendering — while the integration test fails on the emitted global using TopoChan = go.<_chan example.com.mongo_driver.…;.

A non-canonically-aliased import renders foreign types via the file’s alias

A file that imports a package under an explicit alias that differs from the canonical package name must render that package’s types through the alias, not the canonical name. cryptobyte’s asn1.go imports encoding/asn1 as encoding_asn1 — because the sibling vendored subpackage .../cryptobyte/asn1 already claims the canonical asn1 — so a *asn1.BitString parameter must emit ж<encoding_asn1.BitString>. getAliasQualifiedTypeName had rendered the canonical asn1.BitString (importQualifier(pkg.Name())), which the file’s using asn1 = …cryptobyte.asn1_package resolves to the subpackage (no BitString) — CS0426, and the RecvGenerator faithfully propagated the wrong qualifier into its .g.cs. A types.Type carries no source alias, so a per-file importPathAliases map (import path → the alias the file’s using bound) is threaded into getAliasQualifiedTypeName; a foreign type whose import path the file aliased renders through that alias. Only explicitly-aliased imports populate the map — unaliased / blank / dot / Δ-collision-renamed imports are absent and keep the importQualifier(pkg.Name()) fallback, so nothing else changes (value references were already correct — they come from the AST import name via convIdent; only type references, sourced from types.Type, lost the alias). Cleared cryptobyte’s CS0426 (which had masked deeper Builder.add/slice.Value roots, now banked). GUARD OWED — the shape needs two packages whose names collide so one import is forced non-canonical, not expressible in the single-library behavioral corpus.

Struct Type Embedding

Go structs use “type embedding” instead of inheritance. Since converted structs are C# structs (no inheritance), the TypeGenerator manages the equivalent: it adds a field for the embedded type and promotes the embedded type’s fields and methods (selection shorthand). Both field and method promotion are transitive through every embedding level: when top embeds mid which embeds inner, top gets an accessor for inner’s field n (top.n => ref mid.n) and a forwarding receiver for inner’s method describe (top.describe() => target.mid.describe()), each resolving through mid’s own one-level promotion. The generator collects an embedded struct’s members and methods recursively (following each field whose name equals its type’s simple name — Go’s embedding marker), with the closest declaration of a name winning, matching Go’s promotion rules. Pointer embeds promote too. Go also embeds by pointer (*traceBuf), whose C# field type is ж<traceBuf>; its methods and fields are promoted exactly like a value embed (the field’s ref-property is dereferenced — target.traceBuf.Value.method() — which binds the pointer-receiver method via the [GoRecv] ж<T> overload). The embedding-marker comparison dereferences the field type first, because a pointer field’s simple name carries a .Value suffix (traceBuf.Value) that would never match the bare embed field name. This matters most transitively: traceExpWriter embeds traceWriter (value) which embeds *traceBuf (pointer), and traceBuf’s varint/byte must promote all the way up — without the deref-aware marker the nested pointer embed is skipped and the upper struct silently loses the method (CS1929). (Guarded by the NestedEmbeddingPromotion behavioral test for value embeds and the PointerEmbeddingPromotion test for one-level and two-level-transitive pointer embeds; runtime relies on the field case for stackWorkBufstackWorkBufHdrworkbufhdr.nobj and the pointer case for the trace writers.) Because the promotion is performed at conversion time by the generator, methods added later in hand-written C# are not automatically promoted; keeping the source in Go and re-converting (or using explicit interfaces) is the maintainable path.

Zero values of promoted-embed structs construct through a generated constructor — never default. The generator stores each promoted embed in a private readonly ж<T> box that only the type’s constructors allocate, so a default-valued instance has null boxes and the first promoted-member access throws NullReferenceException. Both halves close this: the converter renders every uninitialized declaration of such a struct through the NilType constructor instead of default!var s shadowed emits shadowed s = new(nil);, an uninitialized package-level var g shadowed emits internal static shadowed g = new(nil); (the addressed-global box wraps the same, new(new shadowed(nil))), and a named result (r shadowed) declares shadowed r = new(nil); — while the generator allocates the boxes in the parameterless constructor too, so the new S() zero values materialized by heap(new S(), out var Ꮡs) (an address-taken local) and golib’s @new<T>() (p := new(shadowed), which constructs via Activator.CreateInstance<T>()) are equally usable. The detection (structHasPromotedEmbeds, visitStructType.go) mirrors the embedded-field emission: an embed takes the promoted-box path unless it is a same-package interface, a builtin non-named embed (int), or a pointer to a non-named type; a cross-package embed (selector type) always promotes. Residual gap: an instance materialized as default(T) outside a declaration — a missing-key map read, a freshly maked slice’s elements — still has null boxes; golib cannot run a constructor generically there. (Guarded by the NamedTypeOverStruct behavioral test — var s shadowed with explicit s.ctxt.fn and promoted s.fn access, plus new(shadowed), vs Go.)

A C#-keyword-named embed composes generated names from the unescaped member name. A Go struct named for a C# keyword (type base struct{…}) is emitted with the @ escape (@base), and embedding it makes @base the member name. Standalone identifier positions keep the escape (the partial ref @base @base accessor, the constructor parameter, member accesses like instance.@base.id), but every composed generated name must strip it, because @ is only valid leading an identifier: the promoted-struct box field and its constructor assignments emit Ꮡʗbase (Ꮡʗ@base is CS1002), matching the already-stripped -prefixed field-reference statics and the converter’s structFieldBoxName. (Guarded by the NamedTypeOverStruct extension — a keyword-named embed with promoted field/method access, a keyword-keyed composite literal, and a write through &p.id promoted through the embed, all vs Go.)

Cross-package embeds resolve through the semantic model. The member-collection above resolves the embedded struct’s syntax (GetStructDeclaration) — same-package or via CompilationReferences. In a real MSBuild build, project references arrive as metadata references (never CompilationReference), so a cross-package embed — type rtype struct { *abi.Type } (runtime type.go) or a user package embedding a library struct — silently promoted nothing: the generated “Promoted Struct Field Accessors” section was empty and every t.TFlag/t.Str/t.Kind_ was CS1061. The field collection now falls back to the type’s metadata symbol (GetTypeByMetadataName on the normalized nested name, e.g. go.internal.abi_package+Type) and enumerates its public instance fields; the emitted accessors are unchanged in form — true refs through the embed (public ref abi.TFlag TFlag => ref Type.Value.TFlag; for a pointer embed), so writes through a promoted name reach the embedded target. Transitive promotion through a metadata type’s own embeds is not chased (no corpus site needs it). Promoted POINTER-RECEIVER method calls through a cross-package pointer embed are routed at the call site: the generator emits no method forwarder for a metadata embed (method promotion is syntax-resolved), so t.Uncommon() on Δrtype (embeds *abi.Type, runtime type.go) was CS1929; the converter now emits the explicit hop through the embed field’s box — t.Type.Value.Uncommon() — where the deref’d .Value is a ref return, binding the [GoRecv] ref extension addressably. A same-package pointer embed keeps its generated forwarder (no churn), and a promoted value-receiver method call (p.Hot()) remains a documented open gap — call through the embed explicitly. (Guarded by the CrossPkgUser Phase-4b extension — a promoted pointer-receiver Calibrate through the cross-assembly pointer embed, write-through observed via the target.) (Guarded by the CrossPkgUser Phase-4 extension — pointer-embed and value-embed field promotion across the assembly boundary, write-through observed via the embedded target, vs Go; cleared runtime type.go’s 4 CS1061, 68 → 64.)

Two refinements complete the cross-package pointer-embed story (2026-07-03, internal/reflectlite’s last 4): (a) the hop names the FIELD, which is struct-scoped — an embed field named like a Δ-renamed package type (rtype’s embedded Type vs reflectlite’s Type interface, Δ-renamed ΔType by its type-vs-method collision) is declared unrenamed, so the hop emission must not apply the package-level rename (t.ΔType.Value.Uncommon() was CS1061); both hop arms now route through structFieldBoxName, the same struct-scoped naming the box accessors use. (b) A generated interface implementation forwards through the hop too: when an interface member has NO direct struct method and is satisfied purely by Go promotion through a single embedded-pointer field (GoImplement<rtype, ΔType>Size/Kind live on *abi.Type), the InterfaceImplTemplate emits this.Type.Value.Size() instead of the unbindable this.Size() (CS1929); the IжAdapter template forwards the same members m_box.Value.Type.Value.M(). Detection is syntax-level — the converter’s embed marker is the public partial ref ж<X> F {{ get; }} property (GetEmbeddedPointerHopNames) — and originally gated to a SINGLE hop, on the reasoning that multi-embed interface satisfaction was rare. The corpus surfaced one (jsonrpc’s pipe), and the gate is gone: several embeds now route each member to the unique embed declaring it (see With SEVERAL embedded pointers the hop is chosen per member, not per struct). (Guarded by the CrossPkgUser Phase-5 extension — a local Δ-renamed Meter interface colliding with the embed field name, satisfied purely by promotion through *CrossPkgLib.Meter, with all bump paths aliasing one shared object, vs Go.)

A pointer-receiver method promoted through a VALUE embed is routed at the call site, not by a generator forwarder. When timeTimer embeds timer by value and timer has a pointer-receiver method (func (t *timer) modify(…)), the generator emits no modify forwarder on timeTimer (a target.timer.modify(…) forwarder body would copy the value field, losing the write, and would not bind the ж<timer> overload) — so a promoted call t.modify(…) on a *timeTimer would leave the receiver as the whole ж<timeTimer> box, which the promoted method’s ж/[GoRecv]-ref overload cannot bind (CS1929). The converter instead routes the promoted call through the embedded field’s box, exactly as the explicit t.timer.modify(…) already renders: t.of(timeTimer.Ꮡtimer).modify(…) for a pointer local, Ꮡt.of(timeTimer.Ꮡtimer).modify(…) for a deref’d pointer parameter (the &receiver.field &-machinery supplies the correct box per receiver form). Because it field-refs the real embedded storage — never a Ꮡ(copy) — the mutation writes through. This is detected via the method’s types.Selection.Index() having a single embedded-field hop ([embeddedField, method]); it is gated to a value embed (a pointer embed already yields the box as its field value and is left to the generated forwarder — taking its address would double-box to ж<ж<T>>), and to a single hop (deeper chains fall through).

The pointer-interface ADAPTER projects through VALUE embeds the same way — chained. A GoImplement<T, Iface>(Pointer = true) whose interface members are satisfied only by promotion through value embed(s) — dwarf’s type UintType struct { BasicType }, type BasicType struct { CommonType }, func (c *CommonType) Common() — cannot forward m_box.M() (nothing binds on ж<UintType>, CS1929 ×18). The ImplementGenerator resolves each unbound interface member by walking the single-value-embed chain (syntax marker: the public partial ref X X {{ get; }} property whose name equals its type’s simple name — GetEmbeddedValueHopNames; bounded to 4 hops), composing the box projection hop by hop via the TypeGenerator’s static ref accessors: m_box.of(UintType.ᏑBasicType).of(BasicType.ᏑCommonType).Common(). At each level a direct-ж method binds on the projected box and anything else binds through its deref’d .Value (ref extensions bind on the ref-returning Value) — the same dichotomy as the pointer-embed hop. Mutations write through (the projection field-refs the real embedded storage in the receiver box). (Guarded by StructPointerPromotionWithInterface’s counterKind → kindBase → meta chain: st.Stamp() twice through the interface, Hits() reading the count mutated through the same boxes, vs Go.)

A FOREIGN value embed’s direct-ж method binds through METADATA. When the embedded type lives in another assembly — database/sql’s driverConn value-embeds sync.Mutex, cast *driverConn → sync.Locker — its direct-ж method (Lock/Unlock, emitted by the converter as this ж<Mutex> extensions) is visible only in the compiled sync assembly’s METADATA, never this compilation’s syntax trees. The syntax-based box scan (GetBoxReceiverMethodNames) therefore misses it and the chain-walk fell through to the unbindable m_box.Lock() (CS1929 ×2). The walk now also resolves the embed field’s TYPE SYMBOL and probes its containing package class’s static this ж<T> members via metadata (GetForeignBoxReceiverMethodNames, mirroring the foreignStruct arm’s boxBound scan — only a PUBLIC ж-extension binds cross-assembly, since unexported RecvGenerator twins are internal); when found it forwards the box hop m_box.of(driverConn.ᏑMutex).Lock(), exactly the converter’s own call-site form (Ꮡdc.of(driverConn.ᏑMutex).Lock()). The .Lock() resolves in the generated adapter because sync_package sits in the enclosing go namespace — the same reason the converter’s own call sites bind without a using static. (No single-baseline behavioral guard expresses this — it needs a foreign package’s ж-method type value-embedded AND implementing that package’s interface, the sync.Mutex+sync.Locker shape — so GUARD OWED; verified by a minimal two-assembly reproduction of that exact shape, 2×CS1929 → 0.)

The exception is the enclosing method’s own [GoRecv] ref receiver: a non-direct-ж pointer-receiver method renders this ref T recv with no box (Ꮡrecv exists only for direct-ж), so the box descent referenced a nonexistent name (CS0103 — runtime mgcscavenge.go, (*scavChunkData).alloc/free calling the promoted sc.setEmpty()/setNonEmpty() from the embedded scavChunkFlags). No box is needed either: the embedded field of a ref receiver is addressable, so the promoted method’s [GoRecv] ref overload binds on the explicit field callsc.scavChunkFlags.setEmpty() — with faithful write-through. (A direct-ж target on the bare receiver would have promoted the enclosing method via the capture-mode fixpoint, so this arm’s target always has the ref overload.) The receiver name-match is guarded rendered==raw: an inner binding that shadows the receiver name is Δ-renamed by the shadow pass, declines the arm, and keeps the descent — the same hardening applied in convUnaryExpr’s &recv.field branch, where a pointer local shadowing the receiver name previously took the receiver arm and emitted +raw (a nonexistent box) instead of falling to the pointer-variable arm (cΔ1.of(chunk.Ꮡflags)). The fix also pre-cleared the same latent shape in archive/zip (f.FileHeader.hasDataDescriptor()), go/internal/gcimporter, go/types, and image (whole-stdlib reconvert diff: exactly those sites changed, nothing else). (Guarded by the EmbeddedValuePointerMethod behavioral test — value embed + mutating pointer-receiver methods called via a pointer local, a deref’d param, AND the enclosing [GoRecv] ref receiver, plus a shadowing-pointer-local control, all with write-through verified against Go; runtime relies on it for timeTimer’s modify/stop/reset and scavChunkData’s setEmpty/setNonEmpty.)

A POINTER embed’s BOX-receiver primary promotes through the box hop, not the deref’d value. The promoted-receiver harvest (GetExtensionMethodsIsExtensionMethodForStruct) matched only VALUE-receiver forms (T/ref T/…), so a direct-ж primary (this ж<T>, emitted when a method takes the address of a receiver field) on an embedded type had no promoted forwarder — sha3’s cshakeState embeds *state, whose Write is this ж<state>, so Ꮡc.Write(…) was CS1929. Such a method IS promotable through a pointer embed: the converter renders the hop target.<embed> as a ж<T>, so the forwarder target.<embed>.Write(…) binds the box receiver directly (no box construction). The TypeGenerator now collects those box primaries separately (GetBoxReceiverExtensionMethods, keyed off GetEmbeddedPointerHopNames so it fires ONLY for pointer embeds — a value embed’s target.<embed> is a value that cannot bind a ж-receiver, which would need the box-hop form the sibling GoImplement adapter uses above) and marks each MethodInfo.IsBoxRecv, so the emission drops the .Value a value-receiver forwarder appends (target.<embed>.M(…) for a box primary vs target.<embed>.Value.M(…) for a value method). The pointer-receiver forwarder delegates to the value form unchanged, and the shared box means write-through reaches the real embedded storage. (Guarded by the PointerEmbedBoxReceiver behavioral test — Outer embedding *Inner whose Add takes &n.total (a box primary), the promoted o.Add(…) mutating through the shared box, output-compared vs Go. Full behavioral suite green; a whole-corpus confirmation on the real sha3 is deferred to the next census, as with the sibling foreign-embed fix.)

A promoted field whose name equals the enclosing type is Δ-renamed

Go lets an embedded struct carry a field whose name equals the type doing the embedding — debug/gosym’s type Func struct{ *Sym } where type Sym struct{ Func *Func; … }, so Sym.Func promotes onto Func. The generator’s promoted-field accessor would then emit a Func member inside struct Func, which C# rejects (CS0542 — a member cannot share its enclosing type’s name). The TypeGenerator now Δ-prefixes just that accessor’s NAME when its simple name equals the NonGenericStructName (the field ACCESS on the right keeps the original name), matching the ΔGoType/Δslice collision-rename precedent:

public ref ж<Func> ΔFunc => ref Sym.Value.Func;   // was: `Func => …`, CS0542

The promoted field is read on the embedded struct directly (sym.Func = fn), never via the outer value, so no converter reference to the renamed accessor needs coordinating; a package that did read outerFunc.Func would surface CS1061 in the gate (none does). Cleared debug/gosym’s lone CS0542. Guarded by PromotedFieldNameIsType (a Node embedding a *sym whose Node field collides — accessed through the explicit embedded path, values vs Go).

An EMBEDDED field whose derived name equals the enclosing type is Δ-renamed

The sibling of the case above, one layer earlier: an embedded field’s member name is the unqualified type name (Go spec), so it can equal the enclosing struct’s own name outright. io’s io_test.go declares exactly that — a bytes.Buffer embedded in a struct called Buffer:

// A version of bytes.Buffer without ReadFrom and WriteTo
type Buffer struct {
	bytes.Buffer
	ReaderFrom // conflicts with and hides bytes.Buffer's ReaderFrom.
	WriterTo   // conflicts with and hides bytes.Buffer's WriterTo.
}

The NAMED-field path in visitStructType had renamed such a field since net’s type file struct{ file *os.File } (typeCollidingFieldName, the Δ/ΔΔ rules described under Type-vs-Method Name Collisions), and every ACCESS site already emitted the renamed form — fieldCollidesWithType compares the selector against its enclosing named type without caring whether the field is embedded, and structFieldBoxName runs the same rename for the box accessor. Only the EMBEDDED-field DECLARATION path was out of step: it emitted getCoreSanitizedIdentifier(goTypeName) raw, so the declaration and its accesses disagreed and the struct itself was CS0542:

[GoType] partial struct Buffer {
    public partial ref bytes_package.Buffer ΔBuffer { get; }   // was: `Buffer { get; }`, CS0542
    public io_package.ReaderFrom ReaderFrom;
    public io_package.WriterTo WriterTo;
}

The rename is applied once, before the four embed emission forms (interface embed, the two plain-field arms, and the partial ref promotion), using the same raw compare the named-field path uses (escape and Δ markers stripped on both sides), so the marker doubling for a keyword-family or already-Δ- renamed enclosing type carries over unchanged. Promotion is unaffected — the TypeGenerator derives the backing box and every promoted forwarder from the DECLARED member name, so + the renamed member is what both the generator and the converter’s call sites spell (rb.of(Buffer.ᏑΔBuffer)). The collision is only expressible across packages (one package cannot declare two types with the same name), so it is absent from the single-package behavioral corpus and CNR is byte-identical apart from the new guard. Cleared io’s test-host CS0542. Guarded by EmbeddedTypeNameCollision (a main.Buffer embedding inner.Buffer, exercising BOTH halves the rename must keep consistent — the explicit field selector b.Buffer.Data incl. a write-through, and the promoted fields plus value- and pointer-receiver methods reached through it — with a composite literal keyed by the embedded field and a new(T) zero value, values vs Go).

A pointer-receiver method promoted through two or more embedded VALUE structs descends hop by hop: the first hop through the &-machinery (box-vs-parameter distinction), then one .of(<Owner>.<field-box>) view per additional hop – the ж<T> field views compose onto the method’s receiver box (reflect’s sliceType embeds abi.SliceType embeds abi.Type, whose Common() extension binds ж<abi.Type> – CS1929):

(rg).of(rig.Device).of(CrossPkgLib.Device.Sensor).Calibrate(3);

The own-receiver bare form joins the hop path (recv.E1.E2.method(...)); a chain broken by a pointer embed falls through unchanged. Guarded by CrossPkgUser.

A nil embedded pointer is holdable and assignable — only its dereference panics

Go permits an embedded pointer to be nil: constructing &Setting{name: name} with the embedded *setting unset, comparing it (s.setting == nil), and assigning it after construction (s.setting = lookup(…) — internal/godebug’s Newonce.Do population shape) are all legal; only dereferencing through the nil embed panics. The generator’s promoted-pointer machinery (a ж<ж<T>> box behind a ref accessor) previously conflated the two: the accessor resolved the box with .Value, which treats a null held value as a nil-pointer dereference — so the first touch of an unpopulated embed (even the legal == nil comparison, or the assignment that would populate it) panicked from the accessor. Two generator changes separate holding from dereferencing (StructTypeTemplate):

  1. The promoted-struct accessor resolves through ValueSlot — golib’s nil-check-free real slot. The Ꮡʗ box is a real constructor allocation, so resolving the accessor is a read of the held value, never a dereference of the box; reads and writes of the held ж<T> must not panic.
  2. The parameterized constructors box a nil pointer for an omitted POINTER embed (arg ?? new ж<T>(nil)), matching what the NilType/parameterless constructors already emitted. The held value is then a nil box rather than a raw null, so a genuine deref of the nil embed panics with Go’s runtime error: invalid memory address or nil pointer dereference (recoverable) instead of surfacing an unrecoverable NullReferenceException, and a nil embed compares equal regardless of which constructor produced it (ж.Equals: nil == nil). Value embeds are untouched (their member type is a value type — never null).
// Promoted Struct Accessors
internal partial ref ж<setting> setting => ref Ꮡʗsetting.ValueSlot;   // was .Value — panicked on first touch

// internal ctor: an omitted pointer embed holds a nil BOX, not a raw null
internal Setting(@string tag = default!, ж<setting> setting = default!)
{
    this.tag = tag;
    Ꮡʗsetting = new ж<ж<setting>>(setting ?? new ж<setting>(nil));
}

Go’s nil-deref panic is preserved downstream, where the actual dereference happens: the promoted field/method accessors descend setting.Value.name / target.setting.Value.bump(), and that inner .Value — on the held nil ж<T> itself — still routes through the strict panic path. (Guarded by the EmbeddedPointerNilAssign behavioral test — compare-nil on a fresh instance, a recovered deref panic through the nil embed, assignment of a nil pointer variable, post-construction population, and aliasing through the populated embed, vs Go; each half discriminates independently — without (1) the nil-variable assignment leg panics, without (2) the recover leg crashes with an unrecoverable NRE.)

A field promoted through an embedded POINTER is rooted at the POINTED-TO allocation

A Go pointer’s identity is the storage it names, and f.pfd for type File struct{ *file } is by definition f.file.pfd — one address, whichever spelling reaches it. go2cs encodes a field reference as (containing allocation, field token), so that encoding is right only while the accessor stays inside the allocation it was handed. A promotion that crosses a pointer does not: the generated accessor deref’d on the right,

// was — reaches the right storage, but describes the WRONG allocation
internal static ref FD pfd(ref File instance) => ref instance.@file.Value.pfd;

so of() rooted the resulting pointer at the outer ж<File> box. Reads and writes still landed in the real file.pfd (the ref is correct), which is why nothing looked wrong; only the identity was, and it was wrong in the one way that cannot be seen locally — &f.pfd taken through *File and &file.pfd taken through *file stopped being the same pointer.

The accessor now takes the hop before the reference is built, handing the inner type’s own accessor to the inner box:

// now — the pointer is rooted where Go roots it
internal static ж<FD> pfd(ref File instance) => instance.@file.of(global::go.os_package.file.pfd);

Call sites are untouched (Ꮡf.of(File.Ꮡpfd) still): golib gains a FieldPtrFunc<T, TElem> delegate plus matching of/at overloads, and C# picks the overload by the accessor’s return type. The form composes for a multi-level embed (fileWithoutReadFrom*File*file) because the inner accessor may itself be this shape. Value embeds keep the plain ref form — their promoted fields live in the enclosing allocation, so the existing rooting is already right — and so does a cross-package embed, whose declaration syntax is unavailable to the generator: there the member list comes from metadata and can surface public fields the inner declaration never had (the reflect bridge’s hand-added abi.Type.sysType/arrayDims, promoted into runtime.rtype), for which no inner accessor exists to name. That fallback is fail-loud, not silent — naming a missing accessor is CS0117 at the corpus build.

Two identity fixes in golib sit underneath it, both in ж<T>. A field reference’s SOURCE is now compared by pointer identity, not object reference, because an of() chain mints a fresh intermediate box on every access: Ꮡo.of(Outer.Ꮡin).of(Inner.Ꮡv) allocates a new ж<Inner> each time it is evaluated, so &o.in.v == &o.in.v was false at depth two while correctly true at depth one. Equals, GetHashCode and PointerOrderToken resolve the source through the chain now, the way ReferentObject already did for lifetime questions.

Both defects surfaced as one symptom, and it is worth recording because nothing about that symptom points at pointer identity: internal/poll’s FD.Close hung forever on a file whose Read was still in flight. Close parks on runtime_Semacquire(&fd.csema) until the reader’s readUnlockdestroyruntime_Semrelease(&fd.csema) wakes it, and those semaphores are keyed by pointer identity. The two spellings of &fd.csemaos.close’s, reached through ж<file>, and os.read’s, reached through ж<File> — landed in different buckets, so the release never reached the acquire. Everything else on the path was already faithful: syscall.CancelIoEx really did abort the blocking ReadFile, and the read really did return Go’s file already closed. Guarded by PipeCloseUnblocksRead (a goroutine blocked on a pipe read, a closer, output-compared against go run) and EmbeddedPointerFieldIdentity (depth-2 chain equality, map[*T]V keying, and both spellings of a field promoted through an embedded pointer).

Interfaces

Go interfaces are duck-typed: a type implements an interface simply by having the methods. The converter emits each user-defined interface as a partial interface with a [GoType] attribute, and the ImplementGenerator source generator discovers which concrete types satisfy it and emits the implementing glue plus the implicit conversions. As a result, assigning a concrete value to an interface variable is direct — no reflection lookup or .As(...) call is needed:

type Stringer interface {
    String() string
}

type point struct{ x, y int }

func (p point) String() string {
    return fmt.Sprintf("(%d, %d)", p.x, p.y)
}

func describe() Stringer {
    return point{1, 2}    // point implements Stringer -> assignable directly
}
[GoType] partial interface Stringer {
    @string String();
}

[GoType] partial struct point {
    internal nint x, y;
}

[GoRecv] internal static @string String(this ref point p) {
    return fmt.Sprintf("(%d, %d)"u8, p.x, p.y);
}

internal static Stringer describe() {
    return new point(1, 2);   // implicit conversion emitted by ImplementGenerator
}

The well-known built-in interfaces (error, fmt.Stringer, etc.) are hand-written in golib/the baseline rather than [GoType]-generated, but concrete types implement them the same duck-typed way. (Earlier strategies used a generic As/reflection mechanism; that has been superseded by the compile-time source generators.)

Each discovered “concrete type implements interface” pairing is recorded as an assembly-level attribute in the package’s package_info.cs, e.g. [assembly: GoImplement<point, Stringer>], which ImplementGenerator consumes.

Two refinements to the recording pipeline (io’s NopCloser/eofReader, 2026-07-03): (a) the interface-inheritance prune drops only COMMON implementations, and only from the LOWER interface. When an interface embeds others (ReadCloser = Reader + Closer), a type recorded on both the derived and an embedded interface needs only the derived pair — C# interface inheritance covers the embedded one. The prune intersects the two sets and removes the overlap from the embedded interface’s set; it previously intersected in place on the derived set (the HashSet mutates its receiver), which emptied the derived interface’s recordings whenever the overlap was empty — GoImplement<nopCloser, ReadCloser> vanished and the return nopCloser{r} failed CS0029. (b) An INDEX-expression assignment target records against its ELEMENT type. mr.readers[0] = eofReader{} assigns to a []Reader element; the interface-detection previously tested the container’s root identifier (mr — never an interface; Go forbids indexing one), so no pair was recorded and the concrete literal emitted bare. The check now types the whole index expression, and the conversion-recording path keeps the element type rather than redirecting to the container. (Guarded by the InterfaceCasting extensions — rdCloser, an inheriting interface returned concretely while the embedded rdr has its own recording, plus an interface-slice element assignment.)

The prune matches a FOREIGN base by its canonical name: the inheritance tracking stores both the alias render the declaration emits (fs.FileInfo) and the getFullyQualifiedTypeName render (go.io.fs_package.FileInfo) — the implementation-map keys are canonical, so the alias form alone never matched a foreign embed and both the derived and base impls emitted the same explicit members (zip’s headerFileInfo : fileInfoDirEntry + fs.FileInfo, CS8646 ×6/CS0111 ×2). Structural bases track their canonical names the same way. (Guarded by CrossPkgUser’s stamped — a local interface embedding the foreign CrossPkgLib.Labeled, with seal recorded against both; only the derived record survives.)

An embedded INTERFACE FIELD forwards the members it declares in the pointer adapter. zip’s type nopCloser struct { io.Writer } satisfies io.WriteCloser with Write living on the embedded interface VALUE (Go promotes the field’s method set) and Close on the struct. The IжAdapter forwards still-unbound members that the field’s interface declares through the field itself — m_box.Value.Writer.Write(…) (CS1929 with no forward). Detection is semantic (a non-static field whose name equals its interface type’s simple name — the converter emits embeds as real fields), gated to a SINGLE embedded interface field, and filtered to members the field’s interface (including its inherited interfaces) actually declares. (Guarded by InterfaceCasting’s wrapSink{Animal} cast by pointer to the wider speakShutter — both the promoted and the own member called through the interface, runtime-verified vs Go.)

Promoted forwarders through a Δ-renamed embedded interface use the markerless FIELD name. The converter names an embedded field after the Go embed name, so a struct value-embedding an interface whose C# TYPE was collision-renamed (see Type-vs-Method Name Collisions) declares public log.slog_package.ΔHandler Handler; — the marker lives on the type only. testing/slogtest’s type wrapper struct { slog.Handler; mod func(*slog.Record) } (slog has both a Handler type and a Logger.Handler() method, so the type is ΔHandler) broke in BOTH generated wrapper forms because the ImplementGenerator derived the promoted-forwarder field name from the interface TYPE’s simple name: the value partial struct emitted bare ΔHandler.Enabled(…) (CS0103 cross-package, CS0120 same-package where the bare name binds the nested interface type), and the pointer adapter emitted m_box.Value.ΔHandler.Enabled(…) (CS1061). The field name is now the Δ-stripped simple name (GetSimpleName(…, dropCollisionPrefix: true), the same derivation StructTypeTemplate already used for embedded-field accessors) in all three places: the value template’s promoted arm, the pointer arm’s promoted fallback, and the pointer arm’s semantic embedded-interface-field detection (which compares field name to type name and otherwise never matches Handler vs ΔHandler):

// value partial struct — promoted members forward through the field:
public bool Enabled(nint level) => Handler.Enabled(level);
// pointer adapter — through the box value's field:
bool global::go.main_package.ΔHandler.Enabled(nint level) => m_box.Value.Handler.Enabled(level);

An overridden member is untouched (it forwards to the struct’s own method, this.Handle(…) / m_box.Value.Handle(…)), and a non-renamed interface’s simple name has no marker to strip, so every other promotion emits byte-identical code. (Guarded by ShadowedInterfaceEmbed — a Handler interface Δ-renamed by a Logger.Handler() method collision, value-embedded in a wrapper struct that overrides one of its three methods, cast to the interface by BOTH value and pointer, promoted and overridden members runtime-verified vs Go; cleared testing/slogtest’s 6 errors.)

A FOREIGN struct’s adapter forwards a PROMOTED interface method through the box value, not a phantom static. When the pointer adapter is generated in an assembly OTHER than the struct’s — the struct’s package class lives in a different namespace segment, so its extension methods are invisible to extension-method lookup — the ImplementGenerator forwards each interface member through a package-class STATIC call (xcoff_package.ReadAt(m_box, …)). But this only works for a method the struct declares directly (whose RecvGenerator ж/ref static exists). A promoted interface method — debug/buildinfo’s *xcoff.Sectionio.ReaderAt, where Section embeds the io.ReaderAt interface so ReadAt is promoted, not declared — has no such static, so the forward targets a nonexistent overload (CS1501, “no overload takes 3 arguments”). The static forward is now gated on a real box/ref-bound static existing; when absent, the adapter forwards through the box VALUE — m_box.Value.ReadAt(…) — invoking the struct’s own PUBLIC promoted method (the same promotion File.ReadAt etc. rely on). A directly-declared method keeps the static forward unchanged (no churn). (Validated by the core/debug/buildinfo build — its *os.File → io.ReaderAt [direct ReadAt, static] and *xcoff.Section → io.ReaderAt [promoted, box-value] adapters — plus the full behavioral suite + tar/math-big/net corpus; a single-assembly behavioral guard cannot host it — the shape needs a struct embedding a THIRD package’s interface, cast cross-assembly, so GUARD OWED.)

Pointer-sourced interface values use a generated ADAPTER, not the value-boxing partial struct (2026-07-03). A Go interface value created from a pointer — var s Iface = &t, New(new(lockedSource)), Rand{src: &runtimeSource{}} — holds the pointer: every call through the interface mutates the original object, s.(*T) recovers that same pointer, and interface equality is pointer identity. The old emission deref’d the box into the C# interface (~box, boxing a copy) — aliasing divergence — and could not serve direct-ж receiver methods at all (a method that takes the address of a receiver field is emitted with the box AS its receiver, this ж<T>, which a struct’s this can never bind — math/rand lockedSource CS1929). The converter now records such casts as [assembly: GoImplement<T, Iface>(Pointer = true)], and ImplementGenerator emits a sealed adapter class instead:

internal sealed class runtimeSourceжSource : go.math.rand_package.Source, IжAdapter
{
    private readonly ж<runtimeSource> m_box;
    public runtimeSourceжSource(ж<runtimeSource> box) => m_box = box;
    public object? Box => m_box;
    long go.math.rand_package.Source.Int63() => m_box.Int63();  // direct-ж / ж-twin binds the box
    // Equals/GetHashCode delegate to box identity (Go pointer-interface equality)
}

Cast sites emit the adapter around the box (Incrementer inc = new CounterжIncrementer(c);, src: new runtimeSourceжSource(Ꮡ(new runtimeSource()))), covering call arguments, keyed composite-literal fields, and var declarations; a pointer-typed operand in these positions renders as the box (isPointer ident context), not the deref’d receiver ref-local. Member forwarding picks the receiver form per method: direct-ж and [GoRecv] ref-extensions (whose RecvGenerator ж-twin exists) forward to m_box.M(...); plain value-receiver methods forward to m_box.Value.M(...) (Go copies the value at the call). The golib type-assert machinery (_<T>()) unwraps IжAdapter.Box so s.(*T) yields the original ж<T>, and AreEqual unwraps both operands so interface-vs-interface and interface-vs-pointer comparisons are box identity (ж<T>.Equals is already identity-based); iface == ptr/iface != ptr comparisons emit AreEqual(...) with the pointer operand kept as the box (the old iface == ~p deref form compared a copy). Because each adapter is a distinct class, the interface-inheritance de-duplication (dropping GoImplement<T, Source> when GoImplement<T, Source64> exists and Source64 embeds Source) exempts pointer-form pairs — a Source-targeted cast site references runtimeSourceжSource even though runtimeSourceжSource64 also implements Source. VALUE-sourced casts (var s Iface = t) keep the partial-struct implementation — Go copies the value into the interface there, which is exactly C#’s struct-boxing semantic. Known limits (documented, not yet needed by the corpus): a cross-package pointer cast keeps the old deref-copy form (the adapter class only exists in the impl type’s assembly — isLocalImplType gate), and asserting an adapter-held interface to a different interface (s.(Source64) on a Source-created value) is not yet unwrapped. (Guarded by the InterfaceCasting extension — pointer-receiver Counter with a direct-ж member cast to an interface, mutations verified through BOTH the interface and the original pointer, assert-back recovering the same box, and back == c pointer equality, run-verified vs Go; and by InterfaceImplementation’s output comparison — zoo[0] == f interface-vs-pointer identity.)

Non-empty interface-to-interface conversions use a forwarding adapter. A Go interface value may be assigned or passed to another non-empty interface when the source interface method set satisfies the target (var local localLabel = foreign, where foreign is CrossPkgLib.Labeled). C# has no structural conversion between unrelated interfaces, so the converter records the interface pair as [assembly: GoImplement<CrossPkgLib_package.Labeled, localLabel>] and emits the cast site as a generated adapter:

CrossPkgLib.Labeled foreign = new CrossPkgLib.Sensor(Name: "adapter"u8, Temp: 21);
localLabel local = new CrossPkgLib_LabeledlocalLabel(foreign);
fmt.Println(labelOf(new CrossPkgLib_LabeledlocalLabel(foreign)));

ImplementGenerator emits a sealed adapter implementing the target interface and IInterfaceAdapter, stores the source interface value, and forwards each target member to that value. The golib assertion/equality helpers unwrap IInterfaceAdapter.Value before type assertions, Implements<TInterface>, and AreEqual, so the wrapper behaves as an interface view over the original Go interface value rather than a new concrete payload. Guarded by InterfaceToInterfaceAdapter, which imports CrossPkgLib.Labeled, assigns it to a local compatible interface, passes it as a parameter, and output-compares the forwarded calls.

Two rules govern how concrete implementation records are emitted:

Every eligible interface carries runtime duck-typing shells — the sole resolver of a structural assert

The GoImplement STRUCTURAL recorders were a compile-time approximation of Go’s structural satisfaction, incomplete by construction — which is why, once these shells existed, they were retired outright (2026-07-25; see the RETIRED note under Multi-Result Values and Comma-Ok Forms). A dynamic type may live in a package converted after the interface’s own, and then no record can exist: a dynamic type may live in a package converted after the interface’s own, and then no record can exist. io/fs is converted before os, so fs/package_info.cs records only subFS→ReadDirFSos.dirFS is unreachable — and every fsys.(ReadDirFS) against an os.DirFS(…) value silently missed. golib’s structural probe answered the question correctly (StructurallyImplements) but had nothing to construct, so the assertion still failed. That is what kept io/fs at 16/18 (Glob returning nothing, WalkDir seeing only the root).

With no dynamic code generation available (Native AOT), a per-interface compile-time artifact is the irreducible minimum, and it must live in the interface’s own package class — the only placement guaranteed loaded at every asserting site, and the only one that yields a single cross-assembly identity. TypeGenerator therefore emits, for every non-generic, non-constraint, non-empty [GoType] interface, two sibling shells, discovered through a new [GoInterfaceShell] stamp on the interface itself. No static member is added to the interface — that shape would be inherited by every embedding interface, which is both a large CS0108 hiding class and a method-set corruption (no Go type can implement a static helper), and it makes the shell NAMES non-contractual so the generator may disambiguate freely:

[global::go.GoInterfaceShell(typeof(ΔSpeaker<>), typeof(ΔSpeakerObj), "Speak")]
public partial interface Speaker
{
}

Tier 1 — ΔI<ᴛTTarget>, delegate-bound, for a REFERENCE-typed dynamic value (every ж<X> receiver box, i.e. every pointer-sourced Go interface value — the dominant case). Each interface method is a pre-bound delegate, so a forwarded call costs a delegate invocation, not a reflective one; that matters because a wrapper is obtained once and then called possibly millions of times (a duck-typed io.Reader inside io.Copy, a wrapped sort.Interface’s Less/Swap). It closes over the element type — the pointee — because that is what makes both receiver forms bindable, and the dispatch encodes Go’s *T method-set rule directly:

internal sealed class ΔSpeaker<ΔTTarget> : Speaker, IInterfaceAdapter
{
    private delegate global::go.@string SpeakByPtr(ж<ΔTTarget> targetʗ);
    private delegate global::go.@string SpeakByVal(ΔTTarget targetʗ);

    private static readonly SpeakByPtr? s_SpeakByPtr;
    private static readonly SpeakByVal? s_SpeakByVal;

    public global::go.@string Speak()
    {
        if (m_target_is_ptr && s_SpeakByPtr is not null)
            return s_SpeakByPtr(m_target_ptr!);
        else
            return s_SpeakByVal!(m_target_is_ptr ? m_target_ptr!.Value : m_target);
    }

    static ΔSpeaker()
    {
        global::go.AdapterBinder.ResolveReceiverMethods(typeof(ΔTTarget), "Speak", out byPtr, out byVal);
        s_SpeakByPtr = byPtr is null ? null : byPtr.CreateStaticDelegate(typeof(SpeakByPtr)) as SpeakByPtr;
        s_SpeakByVal = byVal is null ? null : byVal.CreateStaticDelegate(typeof(SpeakByVal)) as SpeakByVal;
        
        BoundByPtr = boundByPtr;      // read by the binder BEFORE the shell is handed out
        BoundByVal = boundByVal;
    }
}

Tier 2 — ΔIᴛObj, reflective, for a VALUE-typed dynamic value (the forcing case: os.dirFS is [GoType("@string")] partial struct dirFS, a value type held in an fs.FS). It holds the value as object and forwards through MethodInvokers resolved once per (dynamic type, interface) pair, so it needs no generic instantiation at all. That is not a stylistic choice: under Native AOT ilc roots exactly the instantiations visible in source, and a MakeGenericType driven by a run-time GetType() over a value type is never one of them, so this is the tier that is unconditionally available. It is emitted only when every member survives the object round-trip (a Go variadic tail lowers to params Span<T>, a ref-struct that cannot be boxed):

The forwarder dispatches on ARITY (2026-07-26). GoShellBinding.Invoke used to build a fresh object?[args.Length + 1] on every forwarded call, purely to prepend the receiver — 32 B allocated and zeroed per call even for a Go method with no parameters at all, which is the common case (Len, Error, String, Less). The bound members are static extension methods, so MethodInvoker’s obj is always null and the receiver occupies the first argument slot; the BCL’s fixed-arity overloads take up to four arguments, so Go arities 0–3 now forward with no array and arity 4+ keeps the Span path. Measured on PerfIfaceShell (one object-tier call per iteration, provisional): JIT 633.7 → 588.0 ms, Native AOT 760.1 → 727.8 ms — the AOT column matters more in principle, because the binder’s belt degrades both shell tiers to this one there, so the cost is paid twice per iteration rather than once. The boxed return is not fixable this way: MethodInvoker returns object? and the shell unboxes, and removing that needs a non-reflective forwarder, which needs a generic instantiation — exactly what this tier exists to avoid. (Guarded by the ShellForwardArity behavioral test: one anonymous interface spanning arities 0–5 plus an int-returning and a mixed-parameter shape, every method folding its arguments into the printed result so a dropped, duplicated or reordered argument diverges from go run instead of passing silently. An instrumented run confirms all four fixed arms and the Span fallback are reached.)

internal sealed class ΔSpeakerObj : Speaker, IInterfaceAdapter
{
    object? IInterfaceAdapter.Value => m_target;

    public global::go.@string Speak() => (global::go.@string)m_binding.Invoke(0, m_target)!;
}

The tier choice branches on Type.IsValueType, and either tier belts to the other when construction fails — AOT rooting is source-shape sensitive, so “we never reach that instantiation” cannot be asserted, only constructed. (Honest limit: because tier 1 closes over the pointee, and a pointee is usually a struct, the pointer tier is AOT-graceful rather than AOT-guaranteed — an unavailable instantiation degrades to the object shell, not to a miss.)

One binder owns the method-set discipline. golib’s AdapterBinder resolves every binding through TypeExtensions.GetGoMethodSetCandidates(element, isPointer) — the same receiver rule StructurallyImplements applies, factored out for exactly this reason. So a value-sourced shell binds only value-receiver methods and can never widen a Go method set: PtrOnly{} (whose Speak has a pointer receiver) MISSES, &PtrOnly{} matches, and a pointer source calling a value-receiver method dereferences the box per call, matching Go’s copy-at-the-call. This also fixes a latent over-broad lookup: GetExtensionMethod collapses a closed ж<X> to the open ж<> definition (correct for single-dispatch precedence, wrong for a method-set query), so a name shared across types could bind the wrong receiver; the binder matches on element identity instead.

A candidate’s EMITTED name is not always its GO name — the method set must be read in Go names (2026-08-01). A Go method set is a Go-level fact reconstructed at run time from emitted C#, and the emitted name can carry the converter’s collision-avoidance marker instead of the Go name. A -tests variant Δ-renames a test-file method declarator whose bare name would hijack a same-named dot-imported function at every unqualified call site — B9 in performNameCollisionAnalysis, and a real compile fix, since C# resolves the enclosing class’s method group ahead of any using static import. io’s multi_test.go is exactly that shape: func (c *writeStringChecker) WriteString(string) against the dot-imported io.WriteString, so the method emits as ΔWriteString while io.StringWriter’s member keeps the bare WriteString. StructurallyImplements compared the two emitted names, answered MISS, no shell was built, and MultiWriter’s w.(StringWriter) fell through to the Write leg — which returns the same (n, err), so the divergence was silent: only Go’s own TestMultiWriter_StringCheckCall, which asserts that WriteString was called, could see it. TypeExtensions.GoMethodNameMatches now projects a candidate’s leading ShadowVarMarker away, the same way GoReflect’s type naming (ΔHandleHandle) and struct-field projection already recover a Go name from an emitted one. It runs as a second pass, after an exact-name pass satisfies nothing, so a Δ-renamed candidate can never displace a plainly-named one; AdapterBinder.ResolveReceiverMethods applies the identical two-pass rule, because a binder that bound a method the probe would not have counted is precisely the disagreement GetGoMethodSetCandidates exists to prevent. Measured: io 47 → 48 of 54.

TryCreate is FAIL-SOFT. The structural probe is deliberately NAME-ONLY for an open-generic receiver method — a Go gbox[int] whose Get() T returns an int matches interface{ Get() string } under that weaker rule (measured). Go answers ok=false there, so a shell that cannot be built answers false rather than escaping as an exception; a false-positive match must reproduce today’s harmless miss, never a crash.

Tier order in builtin.TryTypeAssert is unchanged, and the shells fire only where it previously answered MISS: nominal case TIжAdapter unwrap → AdapterRegistry (compile-time adapters) → shell memoImplements<T> gate → shell construction. That is the regression floor — no assertion that already resolved can take a different path. Construction costs on the order of a microsecond and fmt probes three interfaces per formatted value, so the memo ships in the same change: AdapterRegistry caches the (dynamic type, interface) decision, including the negative. It is deliberately not cleared on AssemblyLoad — Go fixes a type’s method set at compile time, and the extension methods carrying it live in that type’s own assembly, which is loaded by definition when we are holding an instance of it. (Extension-method discovery caches are still invalidated on load; only this decision cache is not.)

The two memoized tiers were folded into ONE per-interface itab cache (2026-07-26). The ladder above consulted the nominal AdapterRegistry (a (Type, Type) tuple hash) and then a separate per-interface shell memo, in sequence, on every assert — and for a shell-resolved pair the first of those could never hit, by construction: a hit would have returned before the shell tier was ever reached. Together with a target.GetType() whose result was dead for an interface target (its only consumers sat behind a typeof(T).IsValueType && that short-circuits false), a memoized assert cost three GetType() calls, two dictionary lookups and two RuntimeType property calls before it did any work. builtin.Itab<TInterface> replaces both reads with one: a single entry per (dynamic type, interface), holding the resolver whichever tier produced it — a registered nominal adapter factory, a runtime shell factory, or null for a decided MISS — exactly as Go keeps one *itab per pair. AdapterRegistry is unchanged as the authoritative durable record; the itab is a projection formed by reading it back, never by forming a second decision. IsInterface/IsValueType are hoisted to per-closed-generic statics, and a monomorphic slot sits in front of the dictionary (an assert site is overwhelmingly single-typed, the locality assumption Go’s per-site checks rely on), so the steady-state read is a static field load, an int compare and a reference compare. Tier precedence is unchanged — nominal still wins, shells still fire only where it answers MISS.

Two correctness points are load-bearing. The entry is one immutable object, never two static fields: a torn (type, resolver) pair would silently construct the wrong implementation. And unifying the caches reintroduces the hazard the two separate dictionaries avoided by construction — a shell (or a decided miss) memoized before a lazily-loaded assembly’s module initializer registers the nominal adapter for that same pair — so AdapterRegistry.Register bumps an epoch (only on a TryAdd that actually adds; the registry is first-wins, so a duplicate can invalidate nothing) and every itab entry carries the epoch it was decided under. An entry from an older epoch is simply never matched and is overwritten when its pair is next formed, which needs no clearing step that could race a concurrent fill. Registration is startup-time and append-only, so the steady state never re-forms anything. A side effect of recording the miss as null rather than reading a factory back is that a miss is now stable: previously a pair whose factory exists but throws its binding failure out of construction answered false once and then re-threw on the next assert, because the projection had published that factory.

Measured on PerfIfaceShell (5M iterations, each two asserts plus two forwarded calls; provisional, taken on a contended machine): JIT 789.8 → 683.1 ms for the unified cache alone, → 633.7 ms with the monomorphic slot — 158.0 → 136.6 → 126.7 ns per iteration. Native AOT 976.5 → 866.2 → 760.1 ms, a larger relative win because under AOT both shell tiers are reflective and dictionary work is a bigger share. (Guarded by the ItabLateRegistration behavioral test — shells decided first, a real module initializer registering real generated adapters afterwards, every pair re-formed, misses still misses, two interfaces over the same types kept separate — and by tests/GolibTests/ItabEpochTests, which pins what Go cannot express: tier precedence is deliberately invisible from Go, since a shell and an adapter forward to the same receiver methods, so the epoch’s effect is only observable at the golib level, where a memoized MISS must start succeeding once an adapter is registered for it.)

Guarded by three behavioral projects, each pairing a main package with a sibling library so the interface’s package genuinely cannot see the concrete types (the io/fs shape): NamedInterfacePointerMethodSet (the X3 negative — a value of a pointer-receiver-only type must MISS — plus the positives, a wrong-signature miss and the fail-soft generic-receiver miss), NamedInterfaceLateAssert (a value-typed defined string satisfying a THREE-DEEP embedded interface chain cross-assembly, an unexported interface, and a partial implementer that must not satisfy the derived interface), and NamedInterfaceAdapterIdentity (%T, re-assert, type switch and interface equality through a shell). Counter-proven: with shell emission disabled, all nine NamedInterfaceLateAssert assertions revert to MISS.

The ladder’s CONCRETE-target miss read a custom attribute per call (fixed 2026-07-26) — this was the whole of the Iface benchmark row. Everything above tunes the INTERFACE-target tiers. The concrete-target tiers below them ended in one that answers Go’s rule that only dynamic (anonymous) struct types convert to each other, and it asked that question with Type.IsDynamicType() — whose body is an uncached GetCustomAttribute<GoTypeAttribute>() that materializes a fresh attribute instance on every call. Measured against live golib: 785.92 ns and 368 bytes per call on the JIT, 3,826.27 ns and 2,017 bytes under Native AOT (ILC parses the attribute blob out of the compiled image’s metadata each time and has no equivalent of the JIT’s attribute caching). Every v, ok := x.(SomeStruct) that does not match reached it, so the ordinary named-struct assert — the most common assert in Go code — paid it corpus-wide, and it is what made PerfIface the worst row in the performance table at 158× Go on the JIT and 660× under AOT, a benchmark whose asserts miss on 4 of every 6 iterations. Two layers, one root cause: IsDynamicType now memoizes per type, beside GetStructFieldNames, which memoizes for exactly this reason (deliberately not added to ClearTypeCaches — like the field-name cache, an assembly load cannot change a type’s own attributes); and AssertFacts<T> gains IsDynamic, joining the IsInterface/IsValueType per-closed-generic facts already hoisted there, so a named-struct miss short-circuits before GetType() is even called. Reordering pure predicates in the && chain preserves semantics exactly, and AnonymousStructs pins both directions — an anonymous struct asserted against an identically-shaped anonymous struct must HIT, and against a named struct with the same fields must MISS.

One marker probe answers “is this a wrapper?” for both adapter tiers. With the attribute lookup gone, what remained on the common path was four failing interface type tests per type-switch-plus-assert iteration: builtin.type() probes IInterfaceAdapter then IжAdapter, and TryTypeAssert probes the same two. That is not free, and the asymmetry is the runtime’s, not the emission’s — measured on a boxed value implementing neither, one failing interface test costs ~2.9 ns (JIT) where one failing sealed-CLASS test is below measurement noise, because a failing interface isinst walks the type’s interface map while a failing class test is a short parent-chain compare the JIT inlines. Both consumers ask the same question first — is this object standing in for another value? — and for an ordinary Go value the answer is no, so it is worth exactly one test. IжAdapter and IInterfaceAdapter now share an empty base marker, IGoAdapter, and both call sites probe it once to gate their two tiers. Generated adapters implement the base transitively, so go2cs-gen is unchanged and no emitted C# moves. Ordering is preserved where it is load-bearing: in TryTypeAssert the IжAdapter match leaves the switch but stays BELOW the string and case T arms (an adapter that is itself a T still resolves as that T), and the gate flag is re-read after each unwrap since what an adapter yields need not be one itself; in builtin.type() the string arm moves after the IжAdapter arm, which cannot change an answer because string is sealed and implements neither marker.

Measured end-to-end on PerfIface (20M iterations of one slice-of-interface read, two interface dispatches, one concrete comma-ok assert and a three-case type switch). The per-stage A/B is a --filter PerfIface run: JIT 10,117.8 ms (158.24×) → 458.1 ms (7.20×) for the attribute fix, → 379.7 ms (5.95×) with the marker probe. The published figure is the full-table quiet-machine run, median of 5: JIT 370.1 ms (5.86×) and Native AOT 262.3 ms (4.15×), against the pre-fix 10,117.8 ms (158.24×) and 42,228.2 ms (660.42×) — a 27× and 161× improvement respectively. Peak working set fell 41.3 → 23.1 MB (JIT) and 29.6 → 11.1 MB (AOT) as ~4.9 GB of per-assert attribute garbage stopped being allocated. A decomposition micro-benchmark against live golib (best-of-5, DOTNET_TieredCompilation=0) attributes the whole of it, per iteration of the emitted loop: slice element read 1.8 ns, two interface dispatches 3.9 ns, comma-ok assert 579.0 → 12.1 → 8.1 ns, type switch 16.0 → 11.0 → 4.4 ns — against a floor of 1.2 ns for a plain s is Circle c and 1.4 ns for a bare C# pattern ladder, both indistinguishable from the slice read alone. IfaceShell moved 44.58× → 44.13× → 40.58× across the same two changes — flat for the first (it is the oracle, and does not touch the miss tier) and improving on the second, since the shell tier enters through the same assert. AOT now beats the JIT on this row, reversing the pre-fix order, because ILC’s failing interface type tests are markedly cheaper (3.7 ns for two against 9.2 ns on the JIT).

ANONYMOUS (dyn) interfaces use the SAME shells — the second renderer is gone. An interface literal was the original duck-typing case, and it had its own machinery long before named interfaces got any: TypeGenerator stamped two static ᴛAs<ᴛTTarget> conversion methods plus a ᴛAs(object) overload onto every [GoType("dyn")] interface, emitted a Δ<Iface><ᴛTTarget> wrapper next to it (with a full operator/nil block it never needed), and builtin.TryTypeAssert reached that wrapper by reflecting for the method by name and closing it with MakeGenericMethod. Two renderers of one idea, and the older one carried three real defects the shells do not:

Nothing about the emitted dyn interface changes except the disappearance of ᴛAs: an interface literal is still a [GoType("dyn")] partial interface, still resolved structurally at run time, still fail-soft. The dyn key is no longer read by TypeGenerator at all — its only remaining reader is the runtime’s Type.IsDynamicType, used for Go’s anonymous-struct-to-anonymous-struct conversion, which reads the [GoType] attribute directly. Guarded by the existing dyn corpus (AnonymousInterfaces, DynIfaceParamNameCollision, DynamicInterfaceKeywordMethod, AnonIfaceMethodSetWidening, AnonIfaceThroughPointerAdapter, AnonInterfaceCrossFile, AnonInterfaceSignatureAssert, DerivedInterfaceStructuralProbe, StructuralAssertFailSoftMiss), which is the dyn contract and stayed green through the migration unchanged, plus the PerfIfaceShell performance benchmark, which executes both tiers under a Native AOT publish.

golib’s three hand-written interfaces joined the same mechanism. error (golib), fmt.Stringer and io.Reader (the baseline stubs) predate the marker and expose plain As<T> helpers; TryTypeAssert found those by the same reflective probe and closed them the same way, so deleting the probe would have taken their duck-typing with it. Each is now stamped [GoInterfaceShell(typeof(<I><>), null, "<M>")] — their existing <I><T> carrier class is the delegate-bound generic shell, and always was — and each carrier’s (in T) constructor became (T), because AdapterBinder locates a shell’s constructor by exact parameter type and an in parameter is T& in metadata. null for the object shell is deliberate rather than a gap: a reflective tier would have to reproduce these carriers’ %v/%T formatting contract (error<T>.ToString(format, provider)), so a value-typed error still binds through the generic shell — AOT-graceful, exactly as before. Because a hand-written shell has no ᴛBoundByPtr/ᴛBoundByVal flags, the binder treats those as optional and forces the type initializer explicitly (RuntimeHelpers.RunClassConstructor), so an unbindable pair is still decided — and memoized — at factory-build time rather than rediscovered per construction.

Cross-package pointer-to-interface conversions use the foreign adapter

A pointer-sourced cast to an interface implemented by a FOREIGN type references the foreign assembly’s PUBLIC adapter class - os’s err = &PathError{...} emits new fs.PathErrorжerror(Ꮡ(new PathError(...))), io/fs having generated the adapter from its own GoImplement<PathError, error>(Pointer = true) record. The record’s existence is read from the imported package’s package_info (parseExportedPointerImplements, the same imported-records pattern as GoTypeAlias). The existence key is the shared canonical spelling both sides compose through implementRecordKey<declaring package>|<C# simple type>|<pkg>_package.<Iface> — exactly as the value-implement records do; keeping the package CLASS on the interface side is what stops image’s Paletted→image.Image record from satisfying a Paletted→draw.Image cast and referencing the adapter that implements the WRONG interface (CS1503). The reference goes through the file-local package ALIAS (fs.PathErrorжerror, user-ruled style) via getAliasQualifiedTypeName, which also registers the using — except when that yields a whole-TYPE alias for a collision-renamed foreign type (imageꓸRGBA), which is an identifier and not a path, so the base is rebuilt as the package qualifier plus the type’s EMITTED simple name (image.ΔRGBAжImage). Guarded by CrossPkgUser (rep = mtr -> new CrossPkgLib.MeterжReporter(mtr); &CrossPkgLib.Alarm{} -> error; and the same-simple-name LOCAL Labeledvar localLb Labeled = sp2 takes the LOCAL CrossPkgLib_SensorжLabeled, never the lib’s exported SensorжLabeled).

An EXPLICIT pointer-to-interface conversion — Go’s image.Image(dst) with dst *image.RGBA (image/draw) — is the same interface cast in conversion clothing and routes through the same machinery: isTypeConversion probes the ORIGINAL pointer type against an interface target (the value type alone does not implement it — the elem-only probe misread the conversion as a constructor call, new image.Image(dst), CS0144), and the emission re-renders the argument in its BOX form and CASTS the adapter to the interface — ((image.Image)new image_ΔRGBAжImage(Ꮡdst)) — because the adapter implements its members explicitly, and a chained member access on the conversion result (CrossPkgLib.Labeled(sp).Label()) cannot bind on the adapter class itself (CS1929). (Guarded by CrossPkgUser’s CrossPkgLib.Labeled(sp2).Label() / LabeledOf(sp2) pair, output-compared vs Go.)

%T (and type-name rendering generally) unwraps generated adapters and pointer boxes

Go’s %T prints the interface value’s dynamic Go type*strings.byteReplacer, never an implementation artifact. The managed model interposes artifacts a name renderer must see through (strings’ TestPickAlgorithm, which %Ts each replacer algorithm, printed strings.byteReplacerжreplacer):

The unwrap lives at the shared choke points, so every formatting path agrees: GoReflect.GoTypeName (which reflect.Type.String() serves the converted fmt’s %T from, via the Phase-1 reflection bridge) gains a TryAdapterWrappedType arm, and golib’s builtin.GetGoTypeName (the stub fmt %T, the testing shim’s TestFormat, and interface-conversion panic texts) unwraps IжAdapter.Box/IInterfaceAdapter.Value at the value level and routes ж/adapter/named types through GoReflect.GoTypeName. Adapter detection is structural, never name-parsed for the wrapped type: IжAdapter (or the infix per Symbols.ValueAdapterInfix for value adapters) identifies the adapter, and the wrapped type is read from the adapter’s single one-parameter constructor (ж<T> → pointer-sourced; the struct type → value-sourced). reflect.Kind()/Elem() of an adapter type still report the adapter class (a reflection-bridge follow-up owned with R5’s DeepEqual work), but %T/String() — the only surface the corpus exercises — are Go-exact. (Guarded by FormatTypeAdapters — a two-project behavioral test whose typelib sub-package supplies the foreign value implementer, output-comparing %T over the ж adapter, the local value implementer, the raw box, a plain named struct, the ᴠ adapter, and a nil interface vs go run; without the unwrap it prints main_package+loudжgreeter / ж`1[[go.main_package+loud, …]] / main_package+typelib_Markᴠstamper.)

The VALUE mirror of the explicit conversioncrypto.SignerOpts(sigHash) with sigHash crypto.Hash (crypto/tls, CS0030 ×4) — routes a FOREIGN named non-interface VALUE source through the same convertToInterfaceType machinery, keeping the outer interface cast: ((crypto.SignerOpts)new crypto_HashᴠSignerOpts(sigHash)) plus the local value-form GoImplement<crypto_package.Hash, crypto_package.SignerOpts> record. A plain cast cannot bind here: a foreign value type implements its interfaces via extension methods (never structurally), and the converting assembly cannot partial a foreign type — the same reason the implicit both-foreign value arm exists (syscall.Signalos.Signal). When the defining assembly already implements the pair (its package_info carries the value-form record), convertToInterfaceType falls through and the emission stays the plain cast spelling. LOCAL value sources deliberately keep the plain-cast/partial-impl route (no churn, no redundant records). The outer cast is load-bearing exactly as in the pointer arm — and additionally because var signOpts = … must type as the INTERFACE: each tls site reassigns signOpts to a different adapter two lines later (CS0029 hazard if the var typed as the adapter class). (Guarded by the CrossPkgLib/CrossPkgUser extension — Verdict implements Scored via a value receiver with deliberately NO witness in the lib, CrossPkgLib.Scored(CrossPkgLib.Verdict(4)) converts explicitly in the user package and the same var is then reassigned a local *tallies implementation, output-compared vs Go; whole-stdlib reconvert diff: exactly the four crypto/tls sites plus its package_info record.)

No exported adapter — the LOCAL adapter for a foreign pair. When the defining package never converts the pair itself (os never casts *File to io.Reader, so no record exists to reference), the converting package records GoImplement<os_package.File, io_package.Reader>(Pointer = true) locally and the generator emits a local adapter class for the foreign struct (internal sealed class os_FileжReader; the m_box field is fully qualified). The class name is package-qualified ({pkg}_{Struct}ж{Iface}): two same-named foreign structs adapting to one interface otherwise compose a single colliding class — math/big records both bytes.Reader and strings.Reader against io.ByteScanner (CS0102/CS0111/CS8646 ×8). The local VALUE adapters for foreign structs qualify the same way (syscall_ΔSignalᴠΔSignal); a LOCAL delegate’s value adapter stays bare (funcValueᴠValue). Forwarding decisions come from metadata — the compiled foreign assembly exposes every converter and sibling-generator form as real symbols, so an extension on ж<T> binds the box (m_box.Read(p)) and everything else binds the deref’d value (m_box.Value.M(), ref extensions bind through the ref-returning Value). This replaces the old deref-COPY fallback, so aliasing is faithful: fmt’s Fscan(os.Stdin, …) emits Fscan(new os_FileжReader(os.Stdin), …) (CS1503 ×3, the last fmt family). Guarded by CrossPkgUser (*Probe → Sampler via CrossPkgLib_ProbeжSampler, mutation read back through the original pointer).

A cross-package interface’s unexported sealing marker is stubbed

Go seals an interface to its defining package with an unexported marker methodast.Expr’s exprNode(), ast.Stmt’s stmtNode(), ast.Decl’s declNode(), text/template/parse.Node’s tree()/writeTo(). The method’s C# implementation is an internal extension in the interface’s own assembly (internal static void exprNode(this ref IndexExpr _)), so an adapter generated where the interface is CONSUMED — go/internal/typeparams casting go/ast’s *IndexExpr to ast.Expr, or text/template casting *parse.RangeNode to parse.Node — cannot see it: forwarding m_box.Value.exprNode() is CS1061. The C# interface member itself is public (unexported Go methods render without a modifier), so it is still required — dropping it is CS0535, and an internal interface member cannot be implemented cross-assembly at all. Because Go never lets a sealing marker be called from outside its package, the adapter satisfies the member with a no-op / default! stub instead of forwarding:

void global::go.go.ast_package.Expr.exprNode() { }                       // void marker
global::go.parse_package.Tree global::go.parse_package.Node.tree() => default!;   // non-void marker

The ImplementGenerator flags a method as an inaccessible marker when its Go name is unexported (GetScope == "internal"), its declaring assembly differs from the one the adapter is generated into, and the struct declares no method of that name in the current compilation (MethodInfo.IsInaccessibleMarker); a SAME-assembly impl keeps forwarding (the internal extension is accessible there). Both the pointer (AdapterImplTemplate) and value (ValueAdapterImplTemplate) adapters emit the stub. This greens go/internal/typeparams (whose only errors were the two exprNode forwards) and is a prerequisite for text/template/go/doc. (Guarded by CrossPkgLib/CrossPkgUser: the sealed Emitter interface with an unexported emitNode(), a *Leaf implementing it, cast to Emitter in the consumer assembly — CS1061 without the stub.)

That third clause is the correction the white-box test model forced (2026-08-09, internal/profile). The assembly comparison is a proxy for “there is nothing to forward to”, and it answers wrongly for the one shape where a single Go package spans two C# assemblies: an INTERNAL (white-box) test package. internal/profile’s proto_test.go is package profile — it declares packedInts and its encode/decoder methods for the production package’s own unexported message interface. Same Go package, different C# assembly, and genuinely reachable, because the test model mints an InternalsVisibleTo grant for exactly this. Stubbing there is worse than a compile error: the adapter COMPILES and silently does nothing, so marshal(source) returned an empty buffer and unmarshal decoded nothing, with no diagnostic at any layer. Requiring the absence of a local implementation — the struct’s own value/ref extensions plus its direct-ж primaries — leaves every genuine marker stubbed unchanged, because a FOREIGN struct never declares the sealing method (Go forbids implementing another package’s unexported method at all, so a [GoImplement] record naming an unexported interface method can only come from a struct in that same Go package). The guard is internal/profile’s own banked suite: the shape needs a white-box test package, which the behavioral corpus has no way to express.

A dynamic interface’s runtime conversion class re-escapes a keyword method name

An anonymous or type-asserted interface is lifted to a [GoType("dyn")] partial interface (see Anonymous interfaces used as an adapter target), and for the dynamic form go2cs-gen’s InterfaceTypeTemplate additionally emits a runtime conversion classΔI<ᴛTTarget> : I — that duck-types a target at run time by reflection-binding each interface method to the target’s extension methods (the fallback for a duck-typed assertion the compile-time ImplementGenerator could not resolve). When such a dynamic interface embeds an interface carrying an unexported sealing method whose name is a C# reserved keyword — internal/testenv’s interface{ testing.TB; Deadline() (time.Time, bool) }, where testing.TB has private() — that name must be @-escaped in the generated class. The converter already escapes it in the interface itself (void @private();), but the sealing method reaches the conversion class through the base-interface walk (interfaceSymbol.AllInterfaces), and a symbol name read from Roslyn (IMethodSymbol.Name) arrives UNescaped — unlike a syntax Identifier.Text. Emitting it raw yields void private() and nameof(private); that syntax error corrupts the class body, and because the conversion class is nested inside the public static partial class …_package container the parse recovery ejects every subsequent operator into the static container — CS0715 (“static classes cannot contain user-defined operators”) ×25 plus a CS0246 cascade (~54): 84 errors from one keyword method. (The nesting itself is legal — a non-static class nested in a static class holds instance members and operators fine, as every non-keyword dynamic interface proves; only the broken body triggers the eject.)

The fix re-escapes the name only where it is emitted as its own identifier token — the method declaration (MethodInfo.GetSignature) and each nameof(...) in the reflection-binding static constructor. The compound delegate/field names ({Name}ByPtr, s_{Name}ByPtr) stay on the raw name: a keyword + suffix is never itself a keyword, and @ cannot appear mid-token. EscapeCsKeyword is a no-op for every non-keyword method, so all other dynamic-interface output is byte-identical. Emitted form:

internal class ΔcommandContext_type<ΔTTarget> : commandContext_type
{
    private delegate void privateByPtr(ж<ΔTTarget> targetʗ);              // compound name — raw
    public void @private() {  }                                         // declaration — escaped
    // static constructor:
    extensionMethod = targetType.GetExtensionMethod(nameof(@private));   // nameof — escaped
}

Greens internal/testenv (its only errors were this one method’s cascade). Guarded by the DynamicInterfaceKeywordMethod behavioral test — a named TB interface with a private() sealing method, embedded in a type-assertion’s anonymous interface so the lifted [GoType("dyn")] target’s conversion class must implement the escaped @private(); it does not compile without the fix.

A keyword-named type’s interface adapters escape declarations and compose class names unescaped

A Go type whose name is a C# reserved keyword (type fixed struct{…}, type lock interface{…}) is @-escaped by the converter everywhere it stands as its own identifier token ([GoType] partial struct @fixed, ж<@fixed>, @lock l = f). Two other name paths mishandled such types:

  1. ImplementGenerator’s emitted type positions. A LOCAL struct’s name reaches the generator as a bare Roslyn SYMBOL name — UNescaped, unlike display strings (ToDisplayString() uses CSharpErrorMessageFormat, which escapes, so go.main_package.@lock arrives correct). Emitting the raw name produced partial struct fixed : sizer — which the C# parser reads as a fixed-size-buffer declaration, ejecting mangled members into the static …_package container (CS0708 'main_package.' “cannot declare instance members in a static class” plus a CS1642/CS1663/CS7092 buffer cascade) — and the same raw name inside the pointer adapter’s ж<fixed>. The generator now applies EscapeCsKeyword at those emission sites (InterfaceImplTemplate.StructName, the pointer adapter’s wrapped StructName, and the value-embed hop’s class qualifier); it is a no-op for every non-keyword name.

  2. Adapter class-name composition, BOTH sides. @ is only legal at the START of a C# identifier token, so a keyword part cannot carry its marker into a composed adapter name: the converter emitted new @fixedж@lock(Ꮡf), which lexes as TWO tokens (@fixedж + @lock — CS1526). Both composers now build from UNESCAPED simple names — the converter’s adapterTypeRef/valueAdapterTypeRef via stripSanitizationMarkers (which also clears a pre-qualified os_@fixed-style interior marker), and the generator’s AdapterName compositions via GetUnsanitizedIdentifier — producing fixedжlock/fixedᴠlock. The composed name always contains the ж/ infix or a package prefix, so it is never itself a keyword and needs no marker (the same rule the keyword-method compound names above rely on: a keyword + suffix is never a keyword).

Emitted form (from the KeywordNamedTypes goldens and its generated adapters):

sizer p = new fixedжsizer(f);                          // converter cast site — composed, no marker
@lock lp = new fixedжlock(f);

partial struct @fixed : global::go.main_package.@lock   // generator value-form — escaped declaration

internal sealed class fixedжlock : global::go.main_package.@lock, IжAdapter
{
    private readonly ж<@fixed> m_box;                   // escaped type reference

TypeGenerator and RecvGenerator were already correct — they read syntax Identifier.Text, which keeps the @fixed spelling. Guarded by the KeywordNamedTypes behavioral test: struct fixed value- and pointer-implementing sizer plus a keyword-named interface lock, with a pointer-receiver grow exercising the RecvGenerator ж-twin on the keyword-named receiver.

An interface member’s keyword-named PARAMETERS escape in every generated implementation

The same symbol-vs-syntax asymmetry reaches parameter NAMES. sync.Map’s

func (m *Map) CompareAndSwap(key, old, new any) (swapped bool)

is emitted by the converter with the keyword escaped (any @new), but ImplementGenerator re-reads the members off the interface SYMBOL when it realizes a [GoImplement] record, and IParameterSymbol.Name — like IMethodSymbol.Name above — arrives with the escape stripped. Every template renders that name straight into a declaration and a forwarding call, so the generated explicit implementation emitted object new, which does not lex as a parameter; Roslyn’s recovery reported CS0501 (“must declare a body because it is not marked abstract, extern, or partial”) on the enclosing member — three of them in sync’s converted test build, one per type implementing the test’s mapInterface.

The three symbol→MethodInfo projections (the interface-adapter path, the struct-adapter/explicit-impl path, and MethodInfo’s own IMethodSymbol overload) duplicated the same tuple construction, so the escape lands once in a shared ToParameterInfos extension that all three now call; it also carries the in/ref/out prefix the two adapter paths need (an explicit implementation must reproduce the ref-kind or it matches no member, CS0539). EscapeCsKeyword is a no-op for every non-keyword and for an already-escaped name, so all other generated output is byte-identical:

bool global::go.sync_test_package.mapInterface.CompareAndSwap(object key, object old, object @new)
    => m_box.CompareAndSwap(key, old, @new);

Guarded by the SymbolParameterInfoTests GenTests cases (name escaping, ref-kind composition, and a parse assertion on the rendered declaration + forwarding call — the failure is a parse failure, so the string compare alone would not pin it) and by the InterfaceKeywordParamNames behavioral test, which drives BOTH realization shapes — a pointer-receiver implementation (the ж<T> adapter) and a value-receiver one — through an interface whose members declare new, lock, base and event parameters, values vs Go.

A keyword-named addressed global’s heap-box field strips the escape after the Ꮡ prefix

An address-taken package-level var is backed by a heap-box FIELD plus a ref-returning property (writeAddressedGlobalDecl). A keyword-named such global (var null = json.RawMessage([]byte("null")), net/rpc/jsonrpc) arrives keyword-escaped (@null), and composing the box as + @null places the escape INTERIOR to the identifier token — Ꮡ@null lexes as two tokens (a whole-file syntax cascade). The prefix already de-keywords the composed name (the keyword + affix rule the adapter compositions above rely on), so the field declaration strips the escape — matching every &null use site, which already composed Ꮡnull through boxBaseName:

internal static ж<slice<byte>> null = new(slice<byte>((@string)"null"));
internal static ref slice<byte> @null => ref null.ValueSlot;   // the var itself keeps its escape

var p = null;                                                  // use site, unchanged

Guarded by HeapKeywordVar (a package-level var null written through its pointer and read back both ways), alongside its existing keyword-named LOCAL coverage.

A foreign struct’s promoted method forwards through its value embed

When the adapter’s struct is FOREIGN (defined in another assembly) it binds forwarding from METADATA (the boxBound / refBound scan above); a member neither on its box nor a ref-static falls to m_box.Value.M(). But an interface member the foreign struct PROMOTES through a VALUE-embedded field has no extension on the struct’s OWN package class, so m_box.Value.M() is CS1929 — text/template casting *parse.RangeNode to parse.Node, where RangeNode embeds BranchNode and the exported String lives on BranchNode, not RangeNode. The generator now discovers the foreign struct’s value embeds from metadata (GetForeignValueEmbeds: a member whose name equals its type’s simple name) and, for a still-unbound member the embed’s package class declares as a public value/ref-receiver extension (GetForeignValueReceiverMethods), forwards through the embed’s package-class STATIC:

global::go.@string global::go.parse_package.Stringer.String() =>
    global::go.parse_package.String(ref m_box.Value.BranchNode);

The static form is required because the embed’s namespace is not imported in the adapter file (only using go;), so an instance-form m_box.Value.BranchNode.String() cannot resolve the extension — exactly as the foreign struct’s own extensions route through staticClass. The receiver argument carries the extension’s ref-kind (ref/in/value). Rerouting is gated to a genuinely promoted member (the struct binds it neither directly nor via a box/ref hop), so a struct that declares the method itself is unaffected. This clears text/template’s last CS1929. (Guarded by CrossPkgLib/CrossPkgUser: *Branch, which promotes the exported Emit through its EmitBase value embed, cast to Emitter — CS1929 without the reroute.)

A promoted box-receiver method through an UNEXPORTED value embed is called cross-package via a public forwarder

An EXPORTED, pointer-receiver method that takes the address of a receiver field is emitted as a direct-ж (box-receiver) primary M(this ж<T> …). When such a method is promoted through an unexported VALUE embed — testing.T.Errorf, promoted from the embedded common (type T struct{ common; … }), or go/types’ TypeName/Var/Func, which embed object — an IN-PACKAGE caller renders the descent through the embed’s box-field accessor (see Promoted pointer methods descend multi-hop value-embed chains above):

t.of(testing.T.common).Errorf("…"u8, );   // in-package

Ꮡcommon is the TypeGenerator’s FieldReferences box accessor for the embed, and — matching the embed’s unexportedness — it is internal. So a caller in another package/assembly (crypto/internal/ cryptotest, testing/slogtest, x/net/nettest, go/internal/gcimporter) cannot see it: a cross-assembly reference to an internal member reads as CS0117 (“testing_package.T does not contain a definition for Ꮡcommon”), not CS0122. Every path through the unexported embed (common, Ꮡcommon, its promoted members) is internal, so no converter-only descent can reach it. The fix is two-sided:

(Guarded by PromotedEmbedLib/PromotedEmbedUser: Counter value-embeds an unexported common whose exported Add/Report take &c.sum (box-receiver); the user package calls them on a *Counter local and through a parameter, plus reads the exported Label field for contrast — output-compared vs Go.)

Plain-return-type addendum — a PLAIN (non-box) promoted method returning a public builtin. The box-shim above covers a method emitted as a ж<T> primary (it takes &receiver.field). A method that merely READS a field — testing.common.Name() (func (c *common) Name() string { return c.name }) — is emitted as an ordinary Name(this ref common) extension, so the promotion machinery emits the usual value + box forwarders Name(this ref T) / Name(this ж<T>) with body target.common.Name(). But their scope was downgraded by the RETURN type: @string (and error, bool, nint, … — every golib builtin) is a PUBLIC C# type whose Go-lowercase name the name-based GetScope heuristic reads as unexported, so the forwarder was emitted internal and thus invisible cross-assembly. Cross-package the converter emits the same bare Ꮡt.Name() (the foreign-unexported-value-embed arm fires for EVERY promoted pointer-receiver method, not just box ones), which then bound a same-named FOREIGN extension — x/net/nettest’s timeoutWrapper reads t.Name() == "…", and the only visible Name was flag.Name(ref flag.FlagSet) (flag is imported by testing) → CS1929. The fix keeps the forwarder public when its return type is GENUINELY accessible: go2cs-gen captures the return type’s ACTUAL C# accessibility (MethodInfo.ReturnTypeIsPublic, computed by IsEffectivelyPublicType — the type and every type argument / tuple element / array-or-pointer element is public, treating builtin special types and use-site-bound type parameters as public) and, for the direct-unexported-value-embed case, trusts it over the lowercase name (directEmbedIsUnexportedValue && method.ReturnTypeIsPublic):

public static @string Name(this ж<T> target) { ref var target = ref target.Value; return target.common.Name(); }

Every OTHER promotion keeps the conservative name heuristic (so no golden/compile churn), and an UNEXPORTED enclosing struct still yields an internal forwarder (its ж<T> receiver is internal — a public forwarder there is CS0051, and my change only prevents a downgrade below the struct’s own scope). This greens x/net/nettest (census 271 → 272/302, zero regressions). (Guarded by PromotedValueEmbedLib/ PromotedValueEmbedUser: Widget value-embeds an unexported common whose plain Name() string is read in an expression cross-package, alongside an unrelated Gadget.Name() — the foreign same-named extension — output-compared vs Go; CS1929 without the fix.)

Pointer-expression-receiver addendum. The converter arm above recovers the box from the .of(…) strip of the first-hop &embed address — which assumes the receiver has an addressable base (an ident: a raw-box local, a deref’d param’s Ꮡx). A pointer receiver expression — a type-assert or call chain like go/internal/gcimporter’s pkg.Scope().Lookup(name).(*types.TypeName).Type() — has no such base: the &-machinery boxes a COPY (Ꮡ(x.@object), no .of( anywhere), so the arm silently fell through to the spelled embed hop, internal cross-assembly (CS1061). A follow-up sub-arm recognizes a pointer-typed receiver expression that renders as the raw box (pointer-typed and not deref-aliased) and calls the promoted member straight on it — the box IS the receiver:

pkg.Scope().Lookup(name)._<ж<types.TypeName>>().Type();   // binds the public Type(this ж<TypeName>)

(Guarded by PromotedValueEmbedExprRecv: the promoted Name() called on a map[string]any assert-chain receiver and on a constructor-call receiver, output-compared vs Go; CS1061 without the fix.)

A value embed promotes its pointer-receiver methods into the outer POINTER method set

Go’s rule has no exportedness clause: for type S struct{ E; … } embedding E by value, the method set of *S contains every pointer-receiver method of E, because &s.E is addressable. (The method set of a plain S does not — that half is the narrowing this subsection also has to preserve.)

The shim above emitted exactly that promotion, but only for an unexported embed. That gate arrived with the cross-package-reachability problem it solves (testing.T.Errorf, whose Ꮡcommon accessor is internal) and reads as a scoping decision, which is why it looked harmless: for an EXPORTED embed the accessor is public, so the converter’s own call sites descend inline and never need a shim.

They are not the only reader. golib reconstructs a Go method set at RUN TIME by scanning the EMITTED extension methods (TypeExtensions.GetGoMethodSetCandidates, shared by the StructurallyImplements probe and AdapterBinder’s shell binder — see Every eligible interface carries runtime duck-typing shells). So an un-emitted promotion is not a missing convenience, it is an ABSENT Go method: the type stops satisfying interfaces Go says it satisfies, at every site the compile-time recorders cannot reach.

debug/dwarf is the reached case. Its readType asserts to an anonymous interface —

typ.(interface{ Basic() *BasicType }).Basic()

— which the converter lifts to a package-local [GoType("dyn")] partial interface readType_type. The concrete types (*IntType, *UintType, *CharType, *UcharType, *FloatType, …) satisfy it only through func (b *BasicType) Basic() *BasicType promoted from their exported BasicType value embed, and the value is held as a different named interface (Type) at the assertion site — so no compile-time witness can exist for the pair and the run-time tier is the only thing that can answer. It answered MISS:

panic: interface conversion: interface {} is *dwarf.UintType, not dwarf.readType_type

The gate is now the Go rule — any direct, non-generic VALUE embed promotes its box-receiver primaries — and the shim keeps its ж<S>-only receiver, which is what preserves the narrowing half (a Uint VALUE must still miss the same anonymous interface):

public static ж<BasicType> Basic(this ж<UintType> target) => target.of(UintType.BasicType).Basic();

The of(…) view is load-bearing rather than incidental: it aliases the embedded storage, so dwarf’s caller writing t.Name/t.BitSize through the returned *BasicType reaches the real field. A copy-returning promotion would have compiled, run, and printed plausible zeros.

Reachability addendum — the shim was emitted, and emitted unreachable. Widening the collection gate made the promotion EXIST; it did not by itself make it bindable. The shim’s scope is the shared methodScope, whose return-type downgrade runs the name heuristic — and GetSimpleName reduces a type to its last dotted segment, which for a Go MULTI-RETURN is error). Lowercase. So every tuple-returning promoted method read as unexported and was emitted internal. archive/zip is the reached case: Open, promoted from ReadCloser’s exported Reader embed, returns (io.fs.File, error), so the package’s own test assembly could not bind the shim its ReadCloserfs.FS adapter needed (CS1929, the whole package build-blocked behind it). The accurate test (method.ReturnTypeIsPublic, from IsEffectivelyPublicType, which walks tuple elements) already existed for the plain-return case; it now also applies to IsValueEmbedBoxRecv, which is the stronger case for it — that shim exists precisely to be reachable across assemblies, since it performs a descent the caller cannot spell, so emitting it internal defeats its own purpose. Every other promotion keeps the conservative heuristic.

Only the collection gate widened. The return-type relaxation’s OTHER arm (directEmbedIsUnexportedValue && method.ReturnTypeIsPublic, in the plain-return addendum above) stays on the narrow condition, because it answers a different question — a cross-package call the converter emits as a bare Ꮡt.M() — which remains the unexported-embed case alone.

A named field whose name equals its interface type is NOT an embedded interface

ImplementGenerator forwards an interface member it cannot bind directly through an embedded interface field (zip’s type nopCloser struct{ io.Writer }m_box.Value.Writer.Write(…)), and detects one by NAME: the field’s name equals its interface type’s simple name, modulo the Δ collision marker (the converter names the field after the Go embed, so a Δ-renamed interface TYPE keeps a markerless FIELD — slogtest’s wrapper embeds slog.Handler as Handler).

That test cannot, by itself, be right. Go emits an ordinary named field Type Type and an embedded Type to the same C# field declaration, and only the first promotes nothing. debug/dwarf carries both shapes in ONE struct:

type PtrType struct {
	CommonType        // a real embed — promotes Common()
	Type       Type   // an ordinary field — promotes nothing
}

Common() was therefore forwarded through the FIELD, returning the referenced type’s CommonType instead of the receiver’s own — a silent wrong answer whenever Type was non-nil, and a null dereference when it was not. Five dwarf structs carry that field (QualType, ArrayType, PtrType, StructField, TypedefType).

Resolved by precedence, not by a new signal — none is available, since the two emissions are identical by construction. Promotion through a marker-backed depth-1 value embed (public partial ref CommonType CommonType { get; } — a hard converter marker the name heuristic is not) now resolves BEFORE the interface-field arm:

ж<CommonType> ΔType.Common() => m_box.of(PtrType.CommonType).Common();   // not m_box.Value.Type.Common()

Legal Go guarantees the two can never both be correct at depth 1: promoting one member from two depth-1 embeds is an ambiguity the Go compiler rejects, so a struct where both arms answer is a struct whose “interface embed” is really a plain field. Deeper embed levels stay BELOW the interface arm, matching Go’s shallower-embed-wins rule. Implemented by running the existing .of(…) descent in two passes (maxDepth 1, then 4) so the “what can bind at this hop” logic is not duplicated and cannot drift from itself.

(Both subsections guarded by PromotedEmbedAnonIfaceWitness, which pins the anonymous-interface assert from a named-interface-held value, the two-concrete-type spread, the type-switch form, the ALIASING write back through the promoted pointer, the VALUE-must-miss narrowing, and the Ptr{CommonType; Node Node} shape checking the VALUE Common() returns rather than merely that nothing crashed. debug/dwarf 30 → 40 of 40.)

Cross-package value-to-interface conversions use the local VALUE adapter

A VALUE conversion of a FOREIGN named type to a LOCAL interface (os’s Signal interface is DOWNSTREAM of syscall.Signal — neither assembly can partial the other) records GoImplement<foreign, localIface> locally; the ImplementGenerator detects the foreign struct (different containing assembly, no local declaration) and emits a value adapter class {pkg}_{Struct}ᴠ{Iface} (composed with Symbols.ValueAdapterInfix; package-qualified for a FOREIGN struct — see the pointer-adapter collision note above) wrapping a COPY of the struct — exactly as a Go interface holds a value — with value equality. The conversion site emits new syscall_ΔSignalᴠΔSignal(sig). The adapter’s struct field is fully qualified (GetFullTypeName(true)): the bare name resolved to the LOCAL same-named type when os’s ΔSignal interface shadowed syscall’s ΔSignal struct.

Method forwarding uses the container-qualified static formglobal::go.encoding.binary_package.Uint32(m_value, b) rather than m_value.Uint32(b): converted Go methods are extension methods on the package class the struct nests in, and the instance form only resolves when the generated file has a using for that namespace (using go; covers root-namespace packages like io/os, but a sub-namespace package like encoding/binary never resolved — debug/plan9obj CS1061 ×6). The static form is exactly equivalent and needs no using at all.

BOTH-FOREIGN value pairs take the same route. When the interface is foreign too (debug/plan9obj passes binary.BigEndian, an encoding/binary value, as binary.ByteOrder), the converter first consults the imported package_info records (parseExportedValueImplements, plain or Promoted GoImplement forms): if the defining assembly already implements the pair, the bare value converts implicitly and nothing is recorded. Otherwise the pair is recorded locally and the conversion site wraps in the locally generated value adapter (new binary_bigEndianᴠByteOrder(binary.BigEndian)) — the value sibling of the both-foreign pointer adapter above.

A value adapter declares IValueAdapter, so the runtime sees the Go DYNAMIC TYPE through it. The adapter is a C# wrapper class, but Go’s dynamic type of the interface value it carries is the wrapped struct. golib settles that question at three places — AreEqual (Go ==), TryTypeAssert (x.(T) and every type-switch case guard), and type() (the type-switch operand) — and each one unwraps the OTHER two adapter kinds through their marker (IжAdapter.Box, IInterfaceAdapter.Value) while the value adapter carried no marker at all. So all three answered against the wrapper class:

got := dst.At(0, 10)                 // image: color.Color carrying a color.NRGBA
got == color.NRGBA{}                // Go true  -> C# false (AreEqual: type mismatch, bails
                                     //            before the adapter's own Equals ever runs)
v, ok := got.(color.NRGBA)           // Go ok=true -> C# ok=false
switch got.(type) { case color.NRGBA:  }   // Go matches -> C# fell to default

The panic text made the shape unmistakable: interface conversion: interface {} is colorlike.NRGBA, not colorlike.NRGBA%T already unwrapped (through TryAdapterWrappedType) while the assert did not. The fix is the missing third marker, mirroring the other two exactly:

public interface IValueAdapter : IGoAdapter { object? Value { get; } }

ImplementGenerator’s ValueAdapterImplTemplate now emits it on every adapter — explicitly implemented, so it can never collide with a forwarded Go method named Value (a promoted adapter binds its members by bare name) — and the three sites unwrap it beside the pointer kind through one shared UnwrapAdapter helper, still behind the single IGoAdapter probe that keeps an ordinary Go value at one failing interface test. GetGoTypeName, GoDynamicTypeOf and the reflect-bridge assignability check gained the same arm, and GoReflect.TryAdapterWrappedType switched from a NAME probe for the infix plus a package-class nesting check to the exact marker test. One marker fixes equality, both assert forms, the type switch and %T together — an Equals-only fallback would have fixed equality alone and left the dynamic-type rule weaker.

Unwrapping in the assert’s interface tier matters independently: the adapter class carries only the ONE interface it was generated for, so probing it can never resolve the wrapped struct’s other interfaces — image/gif’s m.ColorModel().(color.Palette) and c.(color.RGBA) are exactly that shape.

Only the CROSS-ASSEMBLY shape reproduces any of this: a same-package value conversion implements the interface on the struct directly (the value-boxing partial-struct implementation), so there is no wrapper and never was a problem — which is why the defect stayed invisible until packages like image/image/draw converted a foreign struct to a foreign interface. (Guarded by the ValueAdapterDynamicType behavioral test: a sibling colorlike package declares the interface and two value-receiver implementers, main does the conversion, and the test exercises == both operand orders, interface-vs-interface equality, both type-assert forms plus a miss, a type switch over both implementers, %T, method dispatch and an interface-keyed map — output-compared vs go run.)

Under -tests, a white-box PRODUCTION type is FOREIGN to the generator — so the name carries the prefix

The two sides of an adapter name must compose it identically, and they answer “is the source type foreign?” by different means: the generator tests the containing assembly, the converter tests the Go package. Under the white-box reference model those two disagree about exactly one set of types — the package under test’s own. go/packages merges the production files into the INTERNAL test variant’s Go package, so pkg == v.pkg reads net.Conn as local; its C# lives in the referenced production assembly, which the generator reads as foreign and therefore prefixes.

The value arm already carried that carve-out (whiteboxProductionTarget, added for encoding/binary). The interface-sourced arm did not, so every cast site named a type that does not exist — 26 CS0426 across seven of net’s internal test files, 55% of everything left after the r27 syntax cascade closed:

new net_test_package.ConnReader(c)        // referenced (arm composed the name unprefixed)
public sealed class net_ConnReader :      // generated (prefixed, in the SAME anchor class)

The anchor was never in doubt — the record lands in package_test_info.cs and the class is generated into the test metadata class both sides name. Only the simple name disagreed. isSameAssemblyPkg is deliberately left alone: it already answers correctly (both reference models clear testPackagePath), and it remains the RECOMPILE fallback’s answer, where production sources really do compile into the test assembly. Behavioral CNR is byte-identical — the shape exists only under -tests.

An exported func type publicizes the unexported types in its signature

An EXPORTED named func type becomes a public C# delegate; an unexported type in its signature — x/text/unicode/bidi’s type Option func(*options), where options is package-private — is then less accessible than the delegate (CS0059, “inconsistent accessibility”). The type-accessibility pass, which already publicizes the unexported types exposed by an exported struct field / package var / method signature, also walks an exported named type whose underlying is a *types.Signature and publicizes the unexported named types in its parameters and results:

type options struct{  }        // unexported
type Option func(*options)       // exported -> public delegate
[GoType] public partial struct options {  }   // publicized to match the delegate
public delegate void Option(ж<options> _);

Only a package with an exported func type over an unexported type is affected (no golden churn). (Guarded by the PublicizedFuncTypeParam behavioral test.)

A func-TYPED exported field or var publicizes the unexported types in the func signature. The accessibility walk that publicizes an unexported type exposed by an exported field / package var (collectUnexportedNamedTypes, CS0052) peels pointer/slice/array/map/chan wrappers to reach the element type — but stopped at a *types.Signature, so an unexported type reachable ONLY through a func-typed field’s signature was left internal. crypto/internal/hpke’s

type hkdfKDF struct{  }                          // unexported
var SupportedKDFs = map[uint16]func() *hkdfKDF{}  // exported var -> public field

emits public static map<uint16, Func<ж<hkdfKDF>>> SupportedKDFs, whose type embeds hkdfKDF through the func RESULT — but [GoType] partial struct hkdfKDF defaulted to internal, less accessible than the public field (CS0052). collectUnexportedNamedTypes now has a *types.Signature case that recurses into the signature’s PARAMS and RESULTS through the same named-only walk (which handles a nested func result in turn), so hkdfKDF is publicized to [GoType] public partial struct hkdfKDF (and its exported methods go public via the receiver-access cascade). Both sides of the signature are covered — a func PARAMETER exposes an unexported type just as a func RESULT does (var Appliers = []func(*cfg)public static slice<Action<ж<cfg>>> Appliers, publicizing cfg). This routes through the named-only collectUnexportedNamedTypes, NOT the signature-context collectSignatureTypes: a lifted anonymous struct/interface written in the func signature stays the CS0050/CS0051 signature domain, so only genuinely func-reachable NAMED types publicize here. (Guarded by the FuncFieldUnexportedType behavioral test — a public map[uint16]func() *hkdfState var whose func result exposes an unexported type, plus a []func(*cfg) var whose func parameter exposes another, output-compared vs Go; both fail CS0052 without the publicize.)

A publicized wrapper reaches through an UNNAMED composite RHS to its element type. A defined type whose [GoType] wrapper is emitted public (exported, or unexported-but-publicized) exposes its written RHS through the wrapper’s Value/ctor/indexer/operators, so an unexported RHS type must be publicized too. This holds not just for a NAMED RHS (type EncoderBuffer encoder) but for an UNNAMED composite RHS whose ELEMENT is an unexported named type: type ringElement [256]fieldElement exposes fieldElement through the array-wrapper’s indexer/Value/ToSpan, so fieldElement must be publicized (crypto/internal/mlkem768, CS0050/CS0051/CS0053/CS0054/CS0056/CS0057). collectPublicizedWrapperRHS therefore feeds the RHS unconditionally to the pointer/slice/array/map/chan-peeling walk (collectUnexportedNamedTypes) rather than gating on a named RHS. The walk has no *types.Struct case, so a struct RHS stays a no-op — an exported field of an unexported struct-field type is the CS0052 domain and is intentionally left internal. (Guarded by the NamedArrayWrapper extension — an exported Grid [3]unit over an unexported unit, output vs Go.)

A test-file exported helper over an unexported PRODUCTION type is emitted internal (the MIRROR)

The publicization passes above run over a single *types.Package and raise a production type’s accessibility to match the exported production surface that exposes it. A _test.go-declared helper is the mirror case and needs the opposite resolution. In the -tests pipeline the production sources are converted first and independently (no test files), so an unexported production type is already emitted internal on disk; the test files are converted afterward. Go’s strconv/internal_test.go declares an EXPORTED helper returning that internal production type:

// internal_test.go (package strconv) — exports access to strconv internals for tests
func NewDecimal(i uint64) *decimal {  }   // decimal is package-private, emitted `internal`

Emitting NewDecimal public (its capitalized name) makes it a public method whose result is the less-accessible internal decimal — CS0050. Publicizing decimal is not the fix here: production was already emitted (and is not re-emitted in the test pass), so the map entry would be inert, and a public API surface for a test-only helper is semantically wrong. In the recompile test model the test assembly is self-contained (production + internal-package strconv + external-package strconv_test files all compile into ONE assembly, with no cross-assembly consumer of a test symbol), so the correct and sufficient resolution is to downgrade the helper to internal:

internal static ж<@decimal> NewDecimal(uint64 i) {  }   // was public → CS0050; internal ≤ any prod access

visitFuncDecl downgrades an exported free function (Recv == nil) declared in a _test.go file when its signature references, in any param/result position (peeling pointer/slice/array/map/chan), an unexported same-package named type declared in a production file (signatureReferencesUnexportedProductionType). The production-file restriction is essential and is what distinguishes this from sort’s example_multi_test.go, whose exported OrderedBy(...) *multiSorter returns an unexported type declared in a test file: that type is publicized AND re-emitted public within the same test pass (the framework above), so its referrer compiles as public and must not be flipped. Only a production-declared unexported type stays internal-on-disk and forces the downgrade. The change fires solely inside _test.go conversion, so normal-path output is byte-identical (check-no-regression clean across the behavioral corpus) and no already-validated package drifts (sort re-validates 63/63, SetOptimize(bool) bool in the same file stays public). This is the blocker that lets strconv’s test host reach compilation of its file-reading suites (TestFp/TestAtof read testdata/testfp.txt via os.Open + bufio.Scanner). No behavioral guard is expressible — the -tests recompile model has no normal-path analogue — so the guard is the strconv pipeline (its internal_test.cs emits NewDecimal internal; the CS0050 no longer blocks).

A publicized unexported interface is emitted public

The accessibility pass records an unexported interface used in an exported surface exactly like a struct or func type — testing’s type testDeps interface { … } reached through func MainStart(deps testDeps, …) *M is interned into packagePublicizedTypes, and visitTypeSpec sets pendingTypeAccess = "public ". But on the EMISSION side, every top-level type-kind emitter consumes v.pendingTypeAccess (struct, array, map, ident, the inline selector/star cases) except visitInterfaceType, which dropped it — so the interface always emitted [GoType] partial interface testDeps, defaulting to C# internal, less accessible than the public member that references it (CS0051). visitInterfaceType now reads-and-clears pendingTypeAccess at entry (so the lifted/anonymous interfaces it visits recursively see an empty value) and folds the modifier into the post-attribute slot, emitting [GoType] public partial interface testDeps. Non-publicized interfaces are unchanged (no churn). (Guarded by the PublicizedInterfaceParam behavioral test — an exported function taking an unexported interface whose method returns a built-in type, output-compared vs Go.) The transitive cascade also walks a publicized interface’s method signatures: the collectMethodSignatureUnexportedTypes fixpoint step walked a type’s named.NumMethods() (declared receiver methods) but that is 0 for a defined interface — an interface’s methods live on its underlying *types.Interface. It now also iterates iface.NumMethods() for a publicized interface, so an unexported NAMED type in a public interface member’s parameter/result signature is publicized in turn (CS0051/CS0050).

…and an interface member is public whether or not the GO method is exported (2026-08-08). That walk still ran each method through a gate that returned early on !method.Exported(). The gate is right for a CONCRETE method — an unexported one emits internal static … sockaddr(this ж<SockaddrInet4> …) and exposes nothing — and wrong for an interface member, which visitInterfaceType emits with no access modifier and which C# therefore makes implicitly public. Go’s case convention simply does not survive into the emitted surface, so it is the EMITTED C# accessibility, not the Go exportedness, that decides what must be lifted; the gate now takes an explicit flag, set only on the interface arm. syscall’s Sockaddr is the archetype and the idiom is deliberate Go: sockaddr() (unsafe.Pointer, _Socklen, error) is unexported precisely so that only the package can implement the interface — a SEALED interface — yet the emitted member returns the unexported _Socklen from a public interface (CS0050 on every unix flavor; Windows spells the same method with int32, which is why the corpus never saw it). go/types is the other reached case, and a subtler one: its exported Object interface has color() color and setColor(color), and the wrapper only ever compiled because the type’s Δ collision-rename made TypeGenerator’s name-based scope rule read the leading Greek capital as exported and emit it public by accident. It is now publicized on purpose. (Guarded by typeAccessibilityInterface_test.go, whose negative controls fail if the gate is dropped outright rather than narrowed — a concrete unexported method must still publicize nothing.)

A public callable’s signature can also reference a lifted anonymous type, which the NAMED-only cascade above cannot reach — testing’s testDeps.CoordinateFuzzing(… corpusEntry …) / RunFuzzWorker / ReadCorpus, where type corpusEntry = struct{…} is an ALIAS to an anonymous struct. The signature type is not a *types.Named but a lift (corpusEntryᴛ1), a synthesized name over a raw types.Type with no *types.Object, so packagePublicizedTypes (keyed by object) cannot hold it. A parallel set packagePublicizedLiftedTypes (keyed by the alias-stripped anonymous types.Type) fills the gap: a SIGNATURE-context walker collectSignatureTypes — used by the exported-func, exported named-func-type, and method/interface-method signature paths — records any lifted anonymous struct/interface it reaches, and the lift emission in visitStructType consults isPublicizedLiftedType and emits public. This is deliberately signature-scoped and does not fold into the shared named-only collectUnexportedNamedTypes: an exported field/var of an anonymous struct is the CS0052 domain (a public struct/var over an internal anon field type is legal while its own enclosing type is internal), so only signature positions lift — keeping golden churn to the one genuinely-affected shape. (Guarded by the PublicizedInterfaceAnonAlias behavioral test — an unexported interface publicized through an exported function, whose method both takes and returns a type = struct{…} alias, output-compared vs Go; it fails to compile with CS0050/CS0051 without the lift publicize.)

Publicized unexported types make their exported methods public

An unexported Go type reachable through an exported surface (an exported var — var BigEndian bigEndian — an exported field, or an exported function’s signature) is emitted public (packagePublicizedTypes, CS0052/CS0050). Its exported methods must then be public too — Go callers hold such values through the exported var and call the methods cross-package, but the receiver-based access rule alone rendered them internal (extension methods invisible outside the assembly: binary.BigEndian.Uint32(...) CS1061). The receiver-access checks in visitFuncDecl treat a publicized receiver as public, and collectPublicizedTypes cascades through the publicized types’ exported method signatures to a fixpoint (a newly public method’s unexported parameter/result types get publicized in turn, or the public method would be CS0050). Unexported methods stay internal regardless.

Structural interface satisfaction emits C# interface inheritance

Go converts fs.File to io.Reader implicitly because the method set suffices; C# interfaces are nominal. When a declared interface’s method set strictly contains an EXPORTED method interface from a directly imported package (checked with types.Implements), the converter emits real C# inheritance at the declaration and skips re-declaring the covered members (redeclaring would HIDE the base member — implementers would need both):

[GoType] partial interface File :
    io_package.ReadCloser
{
    (FileInfo, error) Stat();
}

Every downstream interface-to-interface conversion then becomes an implicit reference conversion — identity-preserving (the dynamic value flows through type asserts, unlike an adapter wrapper) and zero-cost (os’s CopyFS passes an fs.File to io.Copy, CS1503). Details: only the minimal covering set is listed (ReadCloser subsumes Reader/Closer); the strict-subset guard rules out inheritance cycles (equal method sets never inherit); candidates covered by a declared embed are skipped (the embed emission handles those); bases reference the file-local package alias (io.ReadCloser, user-ruled style) via getAliasQualifiedTypeName, which also registers the using — needed because the declaring Go file may not import the candidate’s package (fs.go declares File without importing io); lifted/dyn and constraint interfaces are excluded. Multiple non-subsuming bases sharing a method (CrossPkgLib.Sealed and .Rated both carry Label): both are inherited, and the shared member is re-declared — a member covered by exactly one listed base is inherited/skipped, but one covered by two or more is re-declared so it hides both inherited slots and member lookup through the derived interface stays unambiguous (CS0121). Go needs only one method to satisfy all; the C# implementers satisfy every slot with the same public method. Consequently the converter never records an interface-to-interface GoImplement — the generator’s impl types are structs, and an interface-typed record kills its whole run. Bounds (banked): candidates come from direct imports only — same-package structural pairs, the universe error, and non-imported-package pairs would still surface as compile errors and would need the adapter complement. Guarded by CrossPkgUser (namedLabel : CrossPkgLib_package.Labeled, passed to CrossPkgLib.Describe).

Embedded-pointer hop receivers split per method

An interface member satisfied by promotion through an embedded POINTER field forwards through the hop — but the receiver form depends on the target method: a [GoRecv] ref extension (or struct method) binds the deref’d value (this.File.Value.Name()), while a direct-ж primary (an extension on ж<X> emitted when the receiver escapes — os’s File.Read/Write) binds the box FIELD itself (this.File.Read(p); deref’ing first strands the receiver, CS1929). The generator discriminates by scanning the compilation for this ж<X> extensions — only converter-emitted primaries are visible to the single-pass scan (sibling-generator ж-twins are not), which is exactly the needed split. Applied to both the value-form partial and the pointer adapter’s hop arm. Guarded by StructPointerPromotionWithInterface (Describer over deviceHandle{*Device}).

With SEVERAL embedded pointers the hop is chosen per member, not per struct

The hop forwarding above was gated to a struct with exactly ONE embedded pointer, on the reasoning that multi-embed interface satisfaction was rare. It is not: net/rpc/jsonrpc’s type pipe struct { *io.PipeReader; *io.PipeWriter } (all_test.go:310) gets Read and Write entirely by promotion from two different embeds. With the gate closed, every promoted member fell through to the templates’ bare receiver — m_box.Read(p) / this.Read(p) — which binds nothing on the struct, so C# overload resolution reached the nearest same-named extension anywhere in scope and reported CS1929 naming a type the package never mentions (io_package.Read(ref io_package.LimitedReader, slice<byte>) from a jsonrpc test; likewise os_package.WriteString(ж<os_package.File>, …)). That misdirection is the signature of this defect — it reads as a missing reference and is not one.

The generator now indexes the hop path per member (GetMultiEmbedHopPaths), routing each still-unbound interface member to the UNIQUE embed declaring it — which is precisely Go’s depth-1 promotion rule. A name TWO embeds declare is dropped rather than guessed (Go promotes neither, so only a method the struct declares itself can satisfy the member — *pipe.Close over the Close both *io.PipeReader and *io.PipeWriter declare). Each embed’s method set is read from local syntax where its type is declared in this compilation and from metadata where it is not — a referenced assembly exposes both the converter’s direct-ж primaries and the public RecvGenerator ж-twins as ordinary symbols, which is the whole jsonrpc case. The receiver form keeps the per-method split of the section above: a direct-ж primary binds the embed’s ж field itself (m_box.Value.PipeReader.Read(p)), anything else its deref’d value (m_box.Value.writer.Value.Write(p)). Both emission paths are covered — the pointer adapter and the value-form partial, since a pointer embed’s method set is in the STRUCT’s method set too, so var rw ReadWriter = p (no &) records the pair as well. A member neither resolution places is left unbound and keeps the old fallback, i.e. a loud CS1929 naming it, never a silent wrong receiver.

Note this is not the same machinery as the TypeGenerator’s promoted-method forwarders, which mint M(this ж<Outer> …) on the struct itself: those bail out on an embed with no local declaration (GetStructDeclaration returns null for a metadata-only type), which is why a struct with two LOCAL pointer embeds compiled all along and jsonrpc’s two FOREIGN ones did not. Guarded by MultiPointerEmbedPromotion (local embeds in both receiver forms, foreign embeds *strings.Reader/*strings.Builder resolved from metadata, an overridden Close both embeds declare, and pointer- and value-sourced casts of each, with write-through observed via the original embedded objects, vs Go).

A forwarded multi-value call deconstructs when tuple elements need interface conversion

return newRawConn(f) forwards a (*rawConn, error) tuple into a (syscall.RawConn, error) result list — C# tuple conversions do not consult user conversions element-wise (CS0266). The converter deconstructs into temps and converts each element through the usual interface machinery (which also records the GoImplement pairing):

var (1, 2) = makeRelay();
return (new relayжReporter(1), 2);

Elements whose actual type is itself an interface are left alone (structural inheritance covers those). Guarded by CrossPkgUser (getReporter forwarding makeRelay).

A multi-value call spread into a call’s parameters in an assignment hoists into temps

Go lets a MULTI-VALUE call fill the parameters of an enclosing call — r := t.newRange(t.parseControl("range")), where parseControl returns five values feeding newRange’s five parameters. C# has no splat, so the inner call is deconstructed into markers and passed expanded:

var (6, 7, 8, 9, 10) = t.parseControl("range"u8);
var r = t.newRange(6, 7, 8, 9, 10);

convExprList already performs this expansion, but only when the call’s deferredDecls hoist target is non-nil — passing the whole tuple as one argument is otherwise CS7036 (text/template/parse’s rangeControl). The return-form threads that target (visitReturnStmt); the assignment forms do too, on BOTH lowering branches: the single-declare block and the mixed/escaping block (a pointer-result local that is heap-boxed is not counted in declaredCount, so it takes the latter — the newRange case above). A statement-level f(g()) (a bare expression statement, not an assignment) carries no deferredDecls of its own, so the expansion now falls back to the enclosing ExprStmt’s v.hoistedDecls buffer — testing’s registerCover2(deps.InitRuntimeCoverage()), where InitRuntimeCoverage returns three values:

var (1, 2, 3) = deps.InitRuntimeCoverage();
registerCover2(1, 2, 3);

The hoisted var (…) = …; lands in the statement’s existing hoist buffer, emitted before the statement. Byte-identical corpus-wide except where the pattern occurs (and a harmless renumber of any later temps, since the per-file marker index is monotonic). Guarded by TupleSpreadIntoCall (a value result, an escaping pointer result, and a statement-level spread).

A PACKAGE-LEVEL var initializer has no statement sink at all — var debug = template.Must( template.New("RPC debug").Parse(debugText)) (net/rpc debug.go; also internal/trace/traceviewer) passed the whole (ж<Template>, error) tuple as Must’s one argument (CS7036). There the spill becomes a hidden once-evaluated static tuple FIELD (v.globalDeclHoist, flushed by visitValueSpec before the var’s own field — C# static field initializers run in textual order, the same holder shape visitPackageTupleVarSpec emits for var a, b = f()), and the arguments read its components:

internal static (nint, nint) tuple1ʗ = parts();
internal static nint g = combine(tuple1ʗ.Item1, tuple1ʗ.Item2);

Guarded by the TupleSpreadIntoCall extension (a package-level var spreading a two-value call into a wrapping call, value read back in main).

A range over a pointer-typed type conversion parenthesizes before the deref

Ranging over a pointer to an array implicitly dereferences it — the converter appends .Value to the range expression. When the range expression is itself a pointer-typed TYPE CONVERSION it renders as a C# cast ((ж<array<byte>>)(uintptr)(p), crypto/internal/nistec’s p256 init over (*[43*32*2*4][8]byte)(*p256PrecomputedPtr)). A cast binds LOWER than member access, so a bare append (ж<…>)(p).Value parses as (ж<…>)((p).Value) — the deref lands on the operand, not the cast result (CS1579 “no GetEnumerator” on the box type, CS8130). visitRangeStmt now wraps the range expression in parentheses — ((ж<…>)(p)).Value — whenever the pointer-unwrap deref is active and rangeStmt.X is a *ast.CallExpr whose Fun is a type expression (info.Types[Fun].IsType(), which catches the unsafe.Pointer conversions isTypeConversion deliberately excludes). Byte-identical corpus-wide (the pattern only occurs on a pointer-producing conversion in range position, which never compiled before). Guarded by RangePointerArrayConversion (transpile+compile+target only — the exact cast shape needs an unsafe.Pointer source, whose runtime round-trip golib does not reproduce, so it is not output-compared).

Adapter accessibility: symbol-OR-name on both sides

The adapter class scope cannot be derived from Go name casing alone (error is lowercase yet the golib interface is public METADATA - the name rule made io/fs’s PathErrorжerror internal, CS0122 x40) nor from symbols alone (sibling generators’ public partial modifiers are invisible to a single-pass generator - the symbol rule broke same-assembly interfaces like CrossPkgLib.Reporter). The ImplementGenerator takes symbol-OR-name on the struct AND the interface.

GoImplement records de-duplicate at attribute emission

os converts dirEntry to fs.DirEntry both through its own alias (type DirEntry = fs.DirEntry) and through the io/fs name - two records for ONE interface made the generator emit the explicit implementation twice (CS8646/CS0111). The de-duplication happens at ATTRIBUTE EMISSION with the ALIASED record winning (its simple name resolves via the package usings); normalizing the RECORD KEY instead was twice wrong - qualified attr names break generator name resolution and flip the alias-locality gate. Measurement lesson: those declaration-phase errors had SUPPRESSED all of os’s method-body diagnostics (Roslyn phase gating) - a package is not truly measured until its declaration errors are zero.

The comparison must run on the EMITTED spelling, not the raw registry key (2026-08-08). The covered set is built from exportedTypeAliases, whose values visitTypeSpec has already canonicalized — it reverts a file-local import rename before recording the alias target — while the registry key keeps whatever rendering the cast site produced. os aliases its io import to Δio (io is shadowed once io/fs is in the reference closure), so on the unix flavors unixDirentfs.DirEntry registers as DirEntry and as Δio.fs_package.DirEntry, and neither compares equal to the canonical io.fs_package.DirEntry the covered set holds. Both records were emitted for the one pair and unixDirentжDirEntry was composed twice (CS0102, CS0111 ×9, CS8646 ×4).

That also produced a third adapter spelling, worth recording because it looks like a separate defect and is not: two records composing one adapter name is exactly what adapterNameCollisionSet exists to detect, so it saw a FALSE collision, applied its collision-conditional rule and qualified the foreign side of one cast site to unixDirentжfs_DirEntry — a name neither record produces (CS0246). Removing the duplicate removes the collision, and all three call sites in file_unix.cs converge. The key is now built with qualifyLocalTypeRef, the same canonicalization the emission applies, so the two sides are comparable by construction. Windows is unaffected, and that was measured rather than argued: its os registers the renamed-canonical spelling ALONE, with no alias-keyed record, so nothing is covered and the qualified record is still the only record. (Guarded by implementRecordAliasCanonicalization_test.go, whose controls pin both the Windows shape and a genuinely distinct second implementation that must NOT collapse.)

The interface-inheritance PRUNE exempts pairs that generate their own adapter CLASS. The same attribute-emission stage also drops a “lower” GoImplement record when the SAME implementing type is recorded against a derived interface that C#-inherits it (elf’s errorReader against both io.ReadSeeker and io.Reader — the two value-form partial-struct implementations would implement Read twice, CS0111/CS8646). That prune is only valid for the value-boxing PARTIAL-STRUCT form (one type, one interface list). A pair whose implementation is a DISTINCT generated adapter class must survive, since each cast site references the adapter for the EXACT interface it targets — the ж pointer form was already exempt, and the same now holds for the value-form adapter classes (``): an **interface-sourced** conversion (net/http wraps `net.Conn` values as `io.Reader`/`io.Writer` — the prune dropped both pairs under the also-recorded `Conn→ReadWriteCloser`, so every `new net_ConnᴠWriter(…)` referenced a class the generator never emitted, CS0246 ×17 in net/http and recurring in net/rpc and httputil) and a **foreign-struct value** conversion (`_`) are marked at recording time (`adapterClassImplementations` in `convertToInterfaceType`) and skipped by the prune. (Guarded by `IfaceToIfaceNarrow` — one source interface converted to a full-surface embedded-interface target AND to its narrower bases at argument, assignment, and return positions, dispatch output-compared vs Go.)

Anonymous interfaces used as an adapter target are lifted package-wide

An inline anonymous interface used as a GoImplement target — internal/trace’s readBatch(r interface{io.Reader; io.ByteReader}), whose concrete *bufio.Reader argument is cast to the inline interface — must resolve to a NAMED C# type on every side, or the raw Go structural literal is emitted into the package_info.cs assembly attribute and into the adapter class name (bufio_ReaderжByteReader} — the stray } breaks the C# parse and cascades ~75 syntax errors across the file). visitInterfaceType already lifts the inline interface to a named type (readBatch_r) in the visitor’s per-file liftedTypeMap, but a cast at a DIFFERENT file’s call site (generation.go) has its own visitor and its own map, so convertToInterfaceType saw only the raw *types.Interface and emitted the literal.

The lift is now also recorded in the package-level packageDynamicTypeNames registry — for FUNCTION-scoped lifts too, since a function-parameter anon interface hoists to file level and is referenced cross-file — exactly as anonymous structs already register (visitStructType). convertToInterfaceType resolves an anonymous *types.Interface through the same three steps dynamicStructTypeName uses: this file’s liftedTypeMap, then the shared registry, then a deferred «DYNTYPE:…» marker resolved after the file-visit barrier. The marker survives the adapter-name composition (adapterTypeRef/valueAdapterTypeRef skip the simple-name strip when it is present — the marker resolves as one unit to the already-simple lifted name), and the GoImplement attribute writer resolves or drops it (mirroring the implicit-conversion writer). registerDynamicTypeName keeps the lexically smallest name for a signature so the winner is well-defined even when several files lift the same shape. Emitted form:

// batch.cs (declaring file):
[GoType("dyn")] partial interface readBatch_r : /* io.Reader */  {  }
// generation.cs (cross-file cast site):
(b, gen, var err) = readBatch(new bufio_ReaderжreadBatch_r(r));
// package_info.cs:
[assembly: GoImplement<bufio_package.Reader, readBatch_r>(Pointer = true)]

Clears internal/trace’s 75-error syntax cascade (the residual CS0315 — a named-numeric wrapper not satisfying a lifted operator constraint — is a distinct, deeper root). Guarded by AnonInterfaceCrossFile (a two-file package: file A declares describe(thing interface{ Sizer; Namer }), file B casts a concrete *box to it — the lifted name must flow into the attribute, the adapter, and the signature).

An INITIALIZED var lifts its explicit anonymous declared type too — and a blank name lifts from the GO identifier

visitValueSpec lifts a var whose DECLARED type is an anonymous struct/interface literal, but until 2026-08-09 only on the BODYLESS arm (var x struct{…}). Give the same var an initializer and nothing lifted it, so the raw Go text landed in both the declaration type and the value adapter’s class name — and its braces close the C# member, making every following declaration in the file read as a namespace-level one:

// crypto/ecdh's test half opens with the documented-interface witness idiom:
var _ interface{ Equal(x crypto.PublicKey) bool } = &ecdh.PublicKey{}
// before — CS1519/CS1002 at the site, then CS0106 on every remaining member, CS1022 at EOF:
internal static interface{Equal(x crypto.PublicKey) bool} _1ʗ =
    new ecdhPublicKeyжinterface{Equal(x crypto.PublicKey) bool}((new ecdhPublicKey(nil)));
// after:
[GoType("dyn")] partial interface _1 { bool Equal(cryptoPublicKey x); }
internal static _1 _1ʗ = new ecdh.ΔPublicKeyж_1((new ecdhPublicKey(nil)));

The initialized arm now performs the bodyless arm’s lift (both the struct and the interface twin). Ordering is not a constraint: the adapter name is minted EARLIER in the same iteration by convertToInterfaceType, but as a deferred «DYNTYPE:…» marker, so a lift registered afterwards still resolves it at the file-visit barrier.

The lift is named from the GO identifier, not from csIDName. For an ordinary name the two agree (csIDName is that name sanitized, and getUniqueLiftedTypeName re-sanitizes its argument), but a BLANK _ var’s csIDName is a synthesized temp (_ᴛ1ʗ) that exists in no Go scope — so getUniqueLiftedTypeName’s typeExists check cannot see it and hands the type the field’s own name back, giving one class a nested type and a field both called _ᴛ1ʗ (CS0102). Passing _ finds the blank var among the package’s defs and bumps the type to _ᴛ1, distinct by construction. (Guarded by the AnonInterfaceVarWitness behavioral test — two blank witnesses over different anonymous interfaces, a NAMED anonymous-interface var that is then called through its adapter, an anonymous-struct declared type, and a local interface value of the witness type, output-compared vs Go; and by crypto/ecdh’s banked 47-verdict suite, which is where it was found.)

A collision-renamed type’s pointer adapter composes on the package qualifier, never the whole-type alias

A COLLISION-RENAMED type resolves through a whole-type global using alias — imageꓸRGBA = go.image_package.ΔRGBA, ecdhꓸPublicKey = go.crypto.ecdh_package.ΔPublicKey — which is a single IDENTIFIER, not a qualified path. The adapter is a MEMBER of the declaring package’s class, so composing the adapter infix onto the alias names nothing: imageꓸRGBAжImage, ecdhꓸPublicKeyж_ᴛ1, CS0246. The base is rebuilt as the file’s package qualifier plus the type’s EMITTED simple name — what the declaring generator composed the class from — giving image.ΔRGBAжImage / ecdh.ΔPublicKeyж_ᴛ1.

The FOREIGN-adapter arm carried this rebuild from the start; the SAME-ASSEMBLY arm (the -tests production-under-test package, which compiles into the test assembly) did not, and the asymmetry was invisible because it only bites a type that is BOTH collision-renamed and adapted. crypto/ecdh shows both halves side by side: PrivateKey is not renamed, renders ecdh.PrivateKey, and composed correctly all along, while PublicKey is renamed and did not. Both arms now share wholeTypeAliasAdapterBase, which returns any render that already carries a qualifier untouched — so it is a no-op for every un-renamed type. (The same-assembly arm is a -tests-only shape, so its guard is crypto/ecdh’s banked suite rather than a behavioral project; the foreign arm’s twin is guarded by CrossPkgUser.)

Every type-name render resolves a lifted anonymous struct cross-file

The registry/marker resolution above initially covered only two dedicated call sites (dynamicStructTypeName’s ж.of(…) address-of-field form and convertToInterfaceType), while the GENERAL type-name renderers — getAliasQualifiedTypeName/getFullyQualifiedTypeName, which every other emission path reaches (heap-box declarations, casts, generic arguments…) — still fell through to raw t.String() Go text on a liftedTypeMap miss. So ranging over a package-level anonymous-struct slice declared in a SIBLING file, with the loop variable escaping to a heap box, stringified the element type into the box declaration: bytes’ compareTests ([]struct{a, b []byte; i int}, declared in compare_test.go, ranged from the earlier-sorted bytes_test.go) emitted ref var tt = ref heap(new struct{a <>byte; b <>byte; i int}(), …) — CS1526 plus a ~170-error parser cascade that blocked all of bytes (Phase-4 blocker B8).

Both renderers now resolve a NON-EMPTY anonymous struct/interface through deferredDynamicTypeName before the t.String() fall-through: the shared packageDynamicTypeNames registry (the declaring file may already have been visited — file visits run in deterministic sorted-file order), else the deferred «DYNTYPE:…» marker. The empty struct{}/interface{} are excluded — their raw signatures intentionally map to EmptyStruct/any downstream. The marker payload is now the HEX-ENCODED signature rather than the raw text: these general render paths flow through string transformation passes (convertToCSTypeName rewrites every [/] to </>, alias handling splits on .) that would corrupt an embedded raw signature before the post-barrier resolution could match it back to the registry; hex digits pass through every transform untouched, and the encoding is a pure function of the signature so equal signatures still render the identical (comparable) string. Emitted form:

// zvars.cs (declaring file, visited AFTER the reference):
[GoType("dyn")] partial struct compareTests1 {  }
internal static slice<compareTests1> compareTests = ;
// main.cs (cross-file range + heap box):
foreach (var (_, v1) in compareTests) {
    ref var tt = ref heap(new compareTests1(), out var tt);
    
}

Guarded by AnonStructCrossFile (zvars.go declares compareTests and sorts after main.go, forcing the marker path; avars.go declares sizeTests and sorts before it, taking the direct registry hit — main.go ranges over both with &tt/&st forcing the heap box, output-compared vs Go).

A lifted type name is unique across the PACKAGE, and the -tests variant inherits production’s

Resolution (above) is one half; naming is the other. Every lifted type — an anonymous struct/interface, or a function-local declaration hoisted out of its body — is emitted as a nested type of the single <pkg>_package partial class, so its name has to be unique across the whole package. The uniquing set was per-FILE, which is a scope narrower than the emission target: two sibling files whose lifts reach for the same generated name each believed the name free and both declared it.

Both spellings a lift can start from are exposed to this. An anonymous type with no name of its own falls back to the generic type (rendered Δtype, then Δtypeᴛ1, Δtypeᴛ2, … per collision), and a function-local declaration is prefixed with the method name only — which sibling files legitimately share, since Go allows one probe method per receiver type. encoding/gob hit both at once: production type.cs and the internal-variant encoder_test.cs each lifted a differently-shaped struct{…} to Δtype/Δtypeᴛ1, and the class then carried two definitions of each — CS0579 on the doubled [GoType] attribute plus CS0111/CS0557 on every member go2cs-gen’s TypeGenerator emitted for the duplicate (32 errors, the whole package blocked). Note the failure is not avoided when the two anonymous structs happen to be structurally identical: the second [GoType("dyn")] is still a duplicate attribute.

The claim set is therefore package-scoped (packageLiftedTypeNames, reset per package/variant), with two deliberate exemptions:

// type.cs (production, pinned):        encoder_test.cs (internal variant, steps around):
[GoType("dyn")] partial struct Δtype {  [GoType("dyn")] partial struct Δtype7 {
    internal nint r7;                       internal nint A;
}                                       }

Residual: two package-level anonymous structs that are structurally IDENTICAL but declared in different files still lift to two distinct C# types (one Go type split in two) rather than sharing one. That combination cannot compile today either — it is the CS0579 case above — so nothing regressed; unifying them needs the second declaration’s emission suppressed, not just its name reused.

Guarded by AnonStructCrossFile’s bvars.go/yvars.go (both manifestations, straddling main.go so file order is exercised in both directions) and, for the -tests seed, TestTestVariantPinsProductionLiftedTypeNames.

Function-literal parameters share the body scope

Go declares parameters in the function block, so a body-level fpath, err := ... REUSES a literal’s err parameter. The variable analysis gives literals ONE merged scope (params + body declarations) mirroring real function declarations; a separate param scope had made the := a shadow declaration beside later reuses (CS0841/CS0128, os CopyFS’s WalkDir literal). Guarded by LambdaFunctions (probe).

System-colliding local type names are root-qualified in assembly attributes

A Go package can name one of its own exported types after a top-level C# System type — internal/profile’s ValueType, go/ast’s Object, bytes’ Buffer. The GoImplement/GoImplicitConv assembly attributes generated in package_info.cs sit at file scope, before the namespace line, where both using System; (a csproj global using) and using static go.<pkg>_package; are active — so a bare ValueType is ambiguous between System.ValueType and the package type (CS0104). The emitter root-qualifies any bare, dotless type name matching a curated set of System top-level names at the package class:

[assembly: GoImplement<go.@internal.profile_package.ValueType, message>]
[assembly: GoImplicitConv<go.@internal.profile_package.ValueType, ж<go.@internal.profile_package.ValueType>>(Indirect = true)]

Foreign types are always package-qualified already (dotted) and are left untouched; no non-colliding name changes, so every non-colliding attribute emits byte-identically. (Guarded by the SystemCollidingTypeName behavioral test.)

A name both -tests variant classes declare is qualified with the FILE’s anchor class

The same file-scope ambiguity has a second source under -tests. The merged test metadata carries a using static for the package under test (<pkg>_package) and for the external suite (<pkg>_test_package) — the second one added so an attribute argument can name a type the external test files declare (B3). A Go package is free to declare the same simple type name on both sides, and encoding/gob does: Point and Vector are declared by codec_test.go (package gob) and again by example_encdec_test.go / example_interface_test.go (package gob_test). The bare reference then binds to neither — CS0104 ×3, which blocked the whole package build.

Such a name is emitted class-qualified, with the class the metadata FILE anchors to — its first class, which is also what the go2cs-gen generators host output in:

// package_test_info.cs — anchored at the production class:
[assembly: GoImplement<go.encoding.gob_package.Point, Squarer>]
[assembly: GoImplement<go.encoding.gob_package.Vector, Squarer>]
// package_info_external_test.cs — anchored at the external test class:
[assembly: GoImplement<go.encoding.gob_test_package.Point, Pythagoras>]

The anchor is a property of the file, not of the variant writing it: the external variant also merges its production-anchored partition into package_test_info.cs, and a bare local reference there still means the production class — the very invariant the B4/B5 record split already relies on (isTestAnchoredImplementRecord: “a BARE impl name is a type declared in the external test package itself”). Making the reference explicit states that invariant instead of assuming it. Because the qualification runs LAST in the name pipeline — after stripLocalTypeQualifier — both arrival forms (bare from the declaring variant, class-qualified then stripped from the other) converge on ONE canonical spelling, so the merge HashSet still dedupes them to a single record.

The name set is computed once per -tests conversion from the two loaded variants and is empty otherwise, so nothing outside -tests changes (check-no-regression: byte-identical across all 495 behavioral projects). Guarded by TestAmbiguousVariantTypeNamesAreClassQualified.

Pointers

Pointer conversions use the golib heap box ж<T> (read “zhe”). Taking the address of a value uses the address-of operator (e.g. Ꮡx); an escaping local is allocated via heap(...), and addresses of a struct field or array element are taken through .of(Type.ᏑField) / .at<T>(index).

The box’s value accessors follow one naming scheme (unified 2026-07-02; the checked accessor was previously val): Value is the strict dereference (ref-returning; panics on a nil pointer, as Go does), ValueSlot is its no-check twin (the identical real slot — for reads/writes of a held value that may legally be nil), and DerefOrNull() is the null-box-tolerant extension every pointer ENTRY alias binds through (an extension method is the only ref-returning form C# permits on a possibly-null receiver; it returns a NULL ref when nil, so the alias binds and the panic lands at the body’s own deref). The same Value name is used by the generated named-type wrappers for their underlying-value accessor and by the golib uintptr struct for its raw word — converted code has exactly one spelling for “the value behind this thing”. A Go struct field named val still emits as .val (it is the user’s identifier, not the accessor):

ref var a = ref heap(new array<@string>(2), out var a);  // escaping local
var p = a.at<@string>(0);                                 // &a[0]
var pField = settings.of(settings1.Retries);            // &settings.Retries

A heap-boxed range variable needs the box allocated per iteration. When a for i := range s (or for _, f := range s) variable has its address taken, it escapes — but the foreach already declares that name, so a single ref var i = ref heap(…) before the loop would clash (CS0136). The converter iterates a temp and, inside the body, allocates a fresh box each pass and copies the temp into it:

for i := range s {
    p := &i      // i escapes
    use(p)
}
foreach (var (i1, _) in s) {
    ref var i = ref heap(new nint(), out var i);   // a FRESH box each iteration
    i = i1;
    var p = i;
    use(p);
}

The per-iteration box is required for Go 1.22 loop-variable semantics: each iteration’s variable is distinct, so a stored &i must point to a different box each pass (for i := range s { ptrs = append(ptrs, &i) } yields 0 1 2, not 2 2 2). A non-escaping companion variable still declares directly in the foreach. (Guarded by the RangeVarHeapBox behavioral test — both a within-iteration &i and the stored-pointer distinctness case; runtime exercises it in for i := range stackpool and for _, f := range s.Fields.) A heap-boxed for i := …; cond; post clause variable takes the same per-iteration box through its carrier rewrite — see For-clause variables are per-iteration.

The at<T>(index) element-address accessor takes a nint index. Go permits any integer type as an array/slice index and converts it to int for the access, but C# has no implicit nuint/uint/ulongnint conversion, so a non-int index is narrowed explicitly to match Go’s index-to-int conversion (CS1503 otherwise). An int index, or an untyped int constant (which renders as a plain int literal), is emitted as-is:

var pi = a.at<nint>((nint)(i));        // &a[i]      where i is a uintptr
var pe = a.at<nint>((nint)(g % 2));    // &a[g%2]    where g is a uint (g%2 widens to long in C#)

This is the element-address analogue of the indexed-literal key cast (SparseArray<T>, above) and the IBinaryInteger<T> width-agnostic length params on unsafe.Add/Slice/String. (Guarded by the ArrayWideIndexAddress behavioral test.)

A string indexed by a wide/unsigned integer takes the same (int) cast: a string LITERAL renders as a ReadOnlySpan<byte> ("…"u8) whose indexer takes int, so a uintptr index is CS1503 — runtime heapdump.go’s "0123456789abcdef"[pc&15] emitted "…"u8[(uintptr)(pc & 15)]. The index-expression emission routes a wide-kind index on any string-typed base through the cast — "…"u8[(int)((uintptr)(pc & 15))] — and an @string variable’s indexer binds an int argument too, so both renders are covered; an int/small index is unchanged. (Guarded by the ArrayWideIndexAddress extension — literal and variable string bases with uintptr/uint64 indexes, byte values vs Go.)

The address of a slice element uses the call form Ꮡ(slice, index) (golib overloads Ꮡ<T>(IArray<T>, int) and (…, nint)) rather than at<T>. Go int (→ nint) and the small integer types that implicitly widen to int bind directly, but an unsigned-32-or-wider or 64-bit index (uint/uint32/uint64/uintptr/int64) binds neither overload, so it is cast to int: Ꮡ(s, (int)(i)). Only those wide/unsigned types are cast — an int/nint or small-int index is emitted as-is to avoid churn. (Mirrors the runtime’s &datap.pclntable[funcoff] / &filetab[fileoff], indexed by uint32 offsets. Guarded by the ElementAddressUnsignedIndex behavioral test.)

The Ꮡ(slice, index) form applies to any slice-typed base expression, not just a named slice variable — a method-call result (&b.stk()[0], runtime mprof.go; &StringByteSlice(s)[0], syscall), a builtin/make result, an unsafe.Slice(…) result (reflect), or a slice-expression base (&x[0:cap(x)][cap(x)-1], math/big). Such bases have no bare identifier, so they previously fell out of the (identifier-gated) slice arm into the array branch — a slice’s type name also starts with [ — whose naive fallback textually prefixed onto the postfix chain: Ꮡb.stk().at<uintptr>(0) binds as (Ꮡb).stk()…, referencing a box that does not exist (CS0103), or copy-boxed the slice header (a lost-write latent). The element address of the returned slice view reaches the shared backing array per Go aliasing, so a write through the pointer is visible via the original storage. (Guarded by the NestedFieldElementAddr extension — &st.stk()[0] through a pointer local, write-through vs Go.)

The same (int) narrowing (the shared castWideIntegerToInt helper) applies to the bounds of a 3-index (full) slice s[low:high:max], which lowers to the golib .slice(nint low, nint high, nint max) method: a uintptr/uint/uint32/uint64/int64 bound is cast — stk[:b.nstk:b.nstk] (b.nstk a uintptr) → stk.slice(-1, (int)(b.nstk), (int)(b.nstk)). Go’s own slice bounds are int, so the narrowing matches Go. A plain int/small-int bound is left uncast. (The 2-index range forms s[lo:hi] narrow through getRangeIndexer for the C# [..] range operator; only the 3-index .slice() form needed this.) (Guarded by the Slice3IndexWideBound behavioral test — uintptr/uint/uint64 full-slice bounds on an array and a slice + an int control, values verified vs Go; runtime hits this in mprof’s stk[:b.nstk:b.nstk].)

Address of an element of an array field reached through a pointer or boxed struct. When the array being indexed is a field of a heap-boxed value — &mp.future[i] where mp is a *memRecord, or &g.future[i] where g is an address-taken global — the array field’s address goes through the box-field accessor first, then the element index: Ꮡmp.of(memRecord.Ꮡfuture).at<cycle>(i) (pointer parameter), mp.of(...) (pointer local), Ꮡg.of(rec.Ꮡfuture).at<cycle>(i) (boxed global). A naive prefix on the field read (Ꮡ(~mp).future) instead binds .future to the box value Ꮡ(~mp) (a ж<memRecord>, which has no future member) → CS1061. This requires a matching golib detail: ж<T>.at<TElem>(index) resolves the array through the Value property, not the raw m_val field — for a field-reference pointer produced by of(...), m_val is an empty default and the real array lives behind Value (the same resolution of(...) itself uses). Reading m_val would miss the array → null-deref at runtime even though the C# compiled. (array<T> is a readonly struct over a shared backing T[], so the value Value yields still aliases the real elements; writes through the returned element pointer land.) (Guarded by the PointerFieldArrayElementAddress behavioral test — pointer parameter and pointer local both taking &p.future[i] and mutating through it.)

The RECEIVER’s own array field is the one case that does not take this route: a Go pointer receiver renders as this ref T recv, which has no box companion, so of(...) would name a box that does not exist (Ꮡr.of(RegArgs.ᏑInts), CS0103). It uses the element-aliasing two-arg Ꮡ(recv.field, (int)(i)) instead — correct because copying an array<T> wrapper shares its backing T[]. See Element address of an ARRAY FIELD of the receiver under Slices and Arrays for the write-dropping bug this replaced (compress/flate losing all LZ77 matching).

A colliding pointer-adapter name qualifies its FOREIGN interface side

A pointer-interface adapter class is named [<pkg>_]<structSimple>ж<ifaceSimple>. The STRUCT side is package-qualified when foreign (bytes_ReaderжReader), which keeps two same-named foreign structs adapting to one interface apart. The INTERFACE side had no such treatment — it composed from its bare last-dot segment — so the mirror-image case collided: ONE struct cast to TWO interfaces whose simple names match composes one class name twice (CS0102, CS0111 per member, CS8646).

compress/flate is the case that surfaced it. It declares its own Reader (io.Reader + io.ByteReader), and its tests hand a *bufio.Reader and a *bytes.Reader to NewReader, which casts to both that and io.Reader — so bufio_ReaderжReader and bytes_ReaderжReader were each emitted twice and the package could not build its test host at all. The rule is collision-conditional: only within a group of records composing the same name does the interface side take a package qualifier (bufio_Readerжio_Reader), and the LOCAL member of a group keeps the bare name (at most one member can be local, so that stays unambiguous and preserves the Go-like short form). Qualifying unconditionally was measured and rejected — 644 distinct adapter names across 3,688 construction sites would churn. The entire 302-package production corpus contains no collisions, so the rule is byte-neutral there by construction; it takes a test closure’s extra casts to make one.

Grouping keys on the whole composed name, struct side included. compress/gzip records both <Reader, io.Reader> and <bufio.Reader, flate.Reader> — two records whose interfaces share a simple name but whose struct sides differ, composing ReaderжReader and bufio_ReaderжReader. Keying on the interface name alone would call that a collision and rename a validated package’s adapters for nothing.

The converter and the generator must agree on every name, and neither may guess, so both read the same authority: the final [assembly: GoImplement<…>(Pointer = true)] lines. That set is not known while cast sites are being rendered — it is settled only after the whole package is visited and writePackageInfoFile has applied its alias-covered skip and its interface-inheritance prune — so a cast emits a deferred marker (mirroring the DYNTYPE marker of the anonymous-struct barrier) that resolveAdapterNameMarkers rewrites once the records are final. The marker’s payload is hex-encoded for the same reason DYNTYPE’s is: a rendered type name passes through string transformation passes before reaching the file. Only the INTERFACE side is ever rewritten — the struct side is emitted verbatim, because it is the reference’s path, not just a name fragment: rewriting it turned new os.FileжWriter(f) (namespace os, adapter class FileжWriter, generated in os’s own assembly) into a bare FileжWriter that resolves nowhere. (Guarded by AdapterNameInterfaceCollision — a local Reader and io.Reader reached from one *src, verified by reverting the fix: CS0102 + CS8646 on srcжReader. Unblocked compress/flate’s Phase-4 test host.)

The same Value-not-m_val rule applies to the dereference operator ~. A value read through a pointer — (~c).field, the form the converter emits for c.field where c is a *T — must resolve through Value. For a field-reference pointer (c := &b.wᏑb.of(box.Ꮡw)) or an array-element pointer, the real storage lives behind Value and m_val is an empty default, so operator ~ returning m_val would read a zero-valued copy ((~c).a0) — it compiles but is silently wrong. ж<T>.operator ~ therefore returns value.Value (which resolves struct-field / array-element references and, for a standard pointer, is exactly m_val), matching the IPointer<T>.operator ~ that already did. This surfaced when a defined-type-over-struct’s forwarded fields were read back through a *wrapper, but it is general to any *x.field value read. (Guarded by the NamedTypeOverStruct behavioral test’s read-back path.)

The at<E>(i) element type E is rendered fully-qualifiedat<sync.atomic_package.Int32>, not the file-local alias at<atomic.Int32>. A namespace-rooted type resolves inside namespace go; without any using <pkg> alias, whereas the alias form needs the file to import that package. A file can index a cross-package-typed array field of a struct without ever naming the element type (so Go requires no import, and the converter emits no using atomic), which would leave the alias unresolved (CS0246, e.g. runtime’s tracecpu.go indexing trace.cpuLogWrite). A current-package or basic element renders identically either way, so this is churn-free. (Guarded by the ArrayOfCrossPackageType behavioral test’s &x.c[i] element-address case.)

Using ж<T> rather than the C# ref keyword avoids the escape-analysis complications of passing a ref into code that expects a heap-allocated pointer. This is a simplification that can cost an unnecessary heap allocation when an address is taken; a future escape-analysis pass could keep such values on the stack when it is provably safe, similar to how Go does this at compile time.

Note: a package-level global whose address is taken is backed by a real heap box so that writes through &global (and &global.field) are observed, rather than mutating a copy.

Pointer equality canonicalizes the STORAGE, not the referent — slice/array element identity

Go compares pointers by address: unsafe.StringData(s) == unsafe.StringData(t) is true whenever both strings share the same backing data (a header copy t := s, or strings.Map’s identity fast path returning s unchanged — strings’ TestMap asserts exactly that). ж<T>.Equals already models address identity per referent shape — struct-field refs compare (source, field-identity), array-index refs compare (backing, index), heap boxes compare wrapped-object identity — but the array-index arm compared the IArray instance, and @string.buffer materializes a fresh PinnedBuffer view per access, so two StringData results over the very same bytes never compared equal (“unexpected copy during identity map”). The array-index arm (and the matching GetHashCode) now canonicalizes a PinnedBuffer to the object its GCHandle pins (PinnedTarget, normally the string’s backing byte[]) before the reference comparison, so equal addresses compare equal while everything previously-equal stays equal — the canonicalization only ever adds true results for same-storage-same-index pairs, and distinct-but-equal arrays still compare unequal (Go pointer semantics, never value comparison). strings.Map’s fast path needed no change at all: it already returned s, sharing the backing array through the @string struct copy — only the identity comparison was blind. (Guarded by the StringDataIdentity behavioral output test — header-copy identity true, repeated-call identity true, a runtime copy false, content equality unaffected; before the fix the two identity cases printed false.)

The same class covers ORDINARY slice and array element pointers, which were the larger miss (2026-07-24). Ꮡ(target, index) takes an IArray<T>, an INTERFACE, so passing a slice<T> — a HEADER (backing array + low + len + cap) over storage it merely sharesboxes the header struct afresh on every call. The array-index arm compared those boxes, so &s[0] == &s[0] was false: Go’s most basic pointer identity, violated for every slice element. The blast radius is wider than comparison alone, because GetHashCode canonicalized the same way: two aliasing element pointers landed in different map[*T] buckets, so m[&s[2]] = "two" then m[&s[2]] = "TWO" added a second entry instead of overwriting, and m[&s[2]] read back the zero value. array<T> (and the generated named-array wrappers) had the identical problem, reached through ж<T>.at (&a[i] on a boxed array), which likewise boxes a copy of the wrapper struct.

CanonicalStorage(IArray) is now CanonicalElement(IArray, index), returning the actual storage object plus the ABSOLUTE element index within it — the referent as stored is never the storage itself:

Referent Canonical storage Index
PinnedBuffer (per-access view) the object its GCHandle pins (PinnedTarget) unchanged
slice<T> its backing T[] (m_array) Low + index
a named slice type the backing of the slice<T> its full-window interface sub-slice hands back Low + index
array<T>, named-array wrapper the raw backing via non-generic IArray.Source unchanged
any foreign IArray the referent itself (prior behavior) unchanged

Folding the window offset into the index is what makes every Go alias of one element compare equal, not just the same-header case: a re-slice (&s[1:][0] == &s[1]), a re-slice of a re-slice, an in-capacity append result (&append(s[:0:1], 9)[0] == &s[0]), and a slice over an array (&a[:][i] == &a[i]) all reduce to the same (T[], absolute index) pair. A named slice type wraps a slice<T> it does not expose, so it is unwrapped via view.Slice(0, view.Length) — the same trick slice<T>’s ISlice<T> constructor uses; Source cannot serve, because a slice header’s Source deliberately materializes a detached copy (only array<T>’s is raw).

Canonicalizing array<T> to its backing is sound precisely because Go’s by-value array COPY is emitted as golib’s .Clone() (see Slices and Arrays), giving the copy its own backing — two distinct Go arrays can never canonicalize to the same storage. Like the PinnedBuffer precedent, the change only ever adds true results for same-storage-same-index pairs: distinct backings still compare unequal (&z[0] != &s[0]), distinct indices still compare unequal (&s[0] != &s[1]), and the struct-field and heap-box arms are untouched. This is a golib-only change — no emitted-code difference.

(Guarded by the SlicePointerIdentity behavioral output test — self identity, distinctness, re-slice and re-slice-of-re-slice aliasing, the in-capacity append result, array-vs-slice-over-that-array, struct elements, a write through an element pointer observed through both views, and map[*int] store/overwrite/lookup/miss including a lookup keyed through a different window — vs go run. Counter-proven: pre-fix every identity assertion printed false, both map lookups returned empty, and len(m) grew from 2 to 3 on the overwrite.)

The same “a box is a temporary, the storage is the object” reasoning answers lifetime questions — when the referent dies, and whether two boxes name the same allocation — which runtime.SetFinalizer and sync.Cond’s copy detector both depend on, and which is also why Ꮡ(IArray<T>, index) must take its target by value: see A pointer’s REFERENT, not its box, answers every lifetime and identity question.

A pointer’s nilness and identity are STRUCTURAL — the IsNull / IsNilPointer split

ж<T> answers two different questions that a single predicate used to conflate, and the conflation was a defect class: IsNull was m_isNull || m_val is null, so it reported true for three unrelated things —

  1. THE nil pointer (m_isNull: nil-constructed, or the canonical NilBox) — the only one that is actually nil;
  2. a real address whose reference-typed pointee is legitimately nil&i with i == nil, new(any), a closure-captured p *T local (ж<ж<T>>). In Go these are ordinary non-nil addresses, and *p yields the nil value rather than panicking;
  3. a struct-field or array-element reference box over a reference-typed T&s.next, &elems[i] — whose storage lives in the referenced struct/array, leaving m_val an unused default that reads as null for any reference type. A perfectly valid address, misread as nil purely because of where its storage is.

IsNilPointer (m_isNull) is the STRUCTURAL predicate, and everything about pointer identity keys off it — equality, GetHashCode, PointerOrderToken, the reflection bridge’s IsNil/Elem, and the dereference guard on operator ~. IsNull keeps only case 2 (case 3 is now excluded structurally, alongside the native-address exclusion added earlier for the same reason), and is consulted only where peeking at the value is the actual question: the strict Value getter, PinnedBuffer, and the uintptr/void* address conversions — where a reference-typed pointee has no reportable address at all, so 0/null is the only answer the managed model can give, and for the value-typed pointees that actually cross into native code the two predicates coincide (unsafe.Pointer’s pointee is uintptr, so every IsNull in that class is the structural question).

Fixed consumers: operator ~ (both the ж<T> and the IPointer<T> interface form) guard on the structural predicate and read ValueSlot, so *p on a real address whose pointee is nil yields nil — and a field-reference deref reads the field instead of throwing; DerefOrNil/IsNilStandardPointer likewise, so the pointer-walk re-alias hands back the real slot (a write through it persists) rather than the throwaway; and the reflection bridge’s interface-routed slot read (GoReflect.readSlotViaInterface) and deepValueEqual’s cycle-detection identityRoot ask INilPointer.IsNilPointer instead of the value-peeking property — the latter previously dropped &i-shaped values out of cycle detection entirely.

Two identity rules changed with it. (a) A standard heap box’s identity was formerly derived from the value it held whenever T was a reference type — two distinct boxes wrapping one referent compared equal. That reported &c == &d true for two distinct *int variables holding the same pointer, collapsed map[**int]V{&c: …, &d: …} into a single entry, and made a pointer’s hash mutate when its pointee was assigned, so a key inserted while its pointee was nil could never be found again (m[q] read back the zero value while len(m) still said 1). A pointer’s identity is its storage: a standard box is the storage, so it hashes and compares by its own identity, and &x == &x holds because an addressed Go variable is heap-boxed once. (b) Conversely, two boxes aliasing the same native address (m_nativeAddr) are now the same pointer — a uintptr round-trip mints a fresh box each time, and Go requires (*T)(unsafe.Pointer(p)) == (*T)(unsafe.Pointer(p)).

golib-only change — no emitted-code difference. (Guarded two ways, because the converter routes every reference-typed-pointee deref through .ValueSlot — verified with a 6-shape probe including generics — so operator ~ is unreachable from converted Go and only golib-internal, hand-owned and reflection-bridge code takes it. The Go-expressible half is in the PointerToNilPointerIdentity behavioral output test: distinct-variable identity, map[**int] two-key distinctness, hash stability across a pointee assignment, and reference-typed field-reference deref + map keying — pre-fix &c == &d printed true, the two-key map held 1 entry, and the stable-key lookup read back empty. The unreachable half is in GolibTests.PointerNilPredicateTests, which drives operator ~ (both forms), DerefOrNil, ReadPointerSlot through a hand-written stand-in for a generated named-pointer wrapper, and native-alias identity — 7 of its 10 assertions fail pre-fix.)

Reading a pointer and taking a field pointer allocate NOTHING — the two costs hidden inside ж<T>

Go’s *p and &x.f are free. Both allocated in go2cs, silently — the code was correct, it merely paid — and the bill was visible only where something counted it. os.TestWriteStringAlloc bounds f.WriteString(s) at zero allocations; the measured cost was over nine thousand bytes per call (9,184 through the test pipeline, 9,208 under the standalone probe, which writes to its own file rather than the host’s t.TempDir() one), and a byte-exact decomposition of the probe’s number (markers around every frame of WriteString → File.Write → poll.FD.Write → syscall.Write, arithmetic closing to the byte) put 5,728 of it — 62 % in these two places, not in the defer machinery that was the standing suspicion (the frame for that shape is 192 bytes, near 2 %).

1. IsNull boxed the whole pointee on every dereference — 4,760 bytes (52 %). Value’s standard-box branch guards on IsNull, whose last term is the value-peeking m_val is null (case 2 of the split above — a real address whose reference-typed pointee is legitimately nil). On an unconstrained type parameter is null compiles to box !T; ldnull; ceq, so a term that is constant-false for every struct T still allocated and memcpy’d a full copy of the pointee, on every read. A pointer to a large record paid its own size per dereference: os.file is 592 bytes, and the write path walks eight of() links, each bottoming out in one of these. The term is now guarded by a per-T s_valueCanBeNull (!typeof(T).IsValueType || Nullable.GetUnderlyingType(typeof(T)) is not null), computed from the type rather than by boxing default(T), so type initialization allocates nothing either. The guard also let the peek read the RIGHT storage: a T containing no references keeps its value in the pinnable m_slot and leaves m_val the unused default, so m_val is null answered for the wrong slot and every ж<Nullable<T>> reported nil whatever it held — unreachable from converted code (Go has no Nullable), and wrong, so it is corrected alongside.

2. of(…) minted the untyped accessor wrapper per CALL — 968 bytes (11 %). of<TElem> stores an object-taking wrapper around the typed field accessor. The wrapper closes over nothing but that accessor, so it is a pure function OF it — and the accessor is a static method group, which the compiler already caches to a singleton. Minting the wrapper per call therefore bought a fresh display class plus a fresh delegate (88 bytes) for a value identical every time, on every &x.field in the corpus. It is now memoized per accessor in a ConditionalWeakTable; the keys are weak, so an accessor that is genuinely per-call leaves no permanent entry. Pointer equality is unaffected — it compares the field IDENTITY token (the original accessor), which is what made the per-call wrapper tolerable in the first place.

Together these take os.File.WriteString from 9,208 to 3,168 bytes per call (−65.6 %) — probe and pipeline agreeing to the byte afterwards, the test now printing expected 0 allocs for File.WriteString, got 3168 — and the same two costs were being paid by every pointer read and every field address in every converted package. The row still does not reach zero — the remainder is the ж<T> boxes themselves (1,488 B, of which 608 is one ж<FD> whose inline m_val slot a field reference never uses), the syscall seam’s unsafe.Pointer/heap boxes (784 B), the defer machinery (192 B — the display class and delegate of each capturing defer) and the unsafe.StringData pin (136 B) — inherent to the current pointer and defer models rather than waste inside them. The arc for those is recorded in docs/phase4/BOARD-next-validation-candidates.md.

golib-only change — no emitted-code difference. (Guarded by GolibTests.PointerDereferenceAllocationTests: four measured-byte assertions plus a semantics pair. With the fixes neutered they report 528 B/deref for a 512-byte pointee, 288 B/deref for a reference-bearing one, 32 B/deref through a field-pointer chain, and 200-vs-112 B/call for of(…) against a bare box of the same type — the last stated as a COMPARISON rather than a byte count so it survives any future change to ж<T>’s layout.)

A pointer parameter whose every use is a dereference is a ref parameter — the ж-box ref-lowering

The emitted-form rule (stage A2 of docs/phase4/DESIGN-zh-box-reduction.md, rulings §10.1/§10.3/§10.4): an unexported package-level function’s pointer parameter whose every body use is a dereference (*p, p.f, p[i], range p), a derived address feeding another lowered position (&p.f → a lowered argument), or a forward into another lowered position, emits as a C# ref T parameter instead of the boxed ж<T> — and every call site passes a ref expression instead of minting or carrying a box. A ref T argument is an alias into the caller’s storage the GC tracks and updates, so no pinning, no box, no allocation, and writes through it land in the caller’s storage by construction. The signature reads as Go’s *T, ref reads as Go’s &, and the entry deref preamble disappears because the parameter is the alias:

func p224Sub(out1, arg1, arg2 *p224MontgomeryDomainFieldElement) { ... }
p224Sub(&e.x, &t1.x, &t2.x)
internal static void p224Sub(ref p224MontgomeryDomainFieldElement out1, ref p224MontgomeryDomainFieldElement arg1, ref p224MontgomeryDomainFieldElement arg2) { ... }
p224Sub(ref nonnil(ref e).x, ref nonnil(ref t1).x, ref nonnil(ref t2).x);

What disqualifies (the whitelist argument — any use the classifier does not positively recognize keeps the box): pointer identity or nilness (p == nil, map keys), escapes (returned, stored, captured by a closure or a defer/go argument frame), representation observations (unsafe.Pointer(p), uintptr(p), interface conversions, a method call ON p), re-pointing (p = q), and function-identity escapes (exported [Phase A], func-value uses, //go:linkname registry membership, named pointer types, bodiless assembly stubs, declaration in — or a curated call from — a [module: GoManualConversion] hand-owned file). Blank/unnamed pointer parameters are never candidates (no uses, nothing to gain, and the boxed path owns the synthesized-name conventions). The fixed point is two-sided: a call site whose argument shape has no ref emission row (including the tuple-splat f(g()) form) strips the position rather than dead-ending emission.

The call-site emission rows (each self-checks and falls back to today’s boxed emission wrapped .DerefOrNull() — total over classifier-admitted shapes without coverage ever being a soundness premise):

Go argument lowered emission
&e.x / &p[i] (base is a pointer’s deref alias or a lowered ref param) ref nonnil(ref e).x / ref nonnil(ref p)[i]
&x.f / &s[i] (value-rooted base: local, value param, global, slice) ref x.f / ref s[i]nonnil elided, the base cannot be null
&x (address-taken local/param/result — reverted or kept-box) ref x (the plain local, or the entry ref alias into the surviving box — same storage either way)
a pointer variable/field/deref/assert (carries a box) ref (q).DerefOrNull() — reads the box at CALL time, so a re-pointed pointer is never stale
a lowered ref parameter forwarded ref p — it already is the ref
(*T2)(&v.x) (the named-array-wrapper reinterpret — §10.3’s hoisted-temp rule) var ᴛ1 = nonnil(ref v).x.Value;ref ᴛ1 — the wrapper’s Value yields its array<T> header, a copy whose T[] backing is SHARED, so element writes flow through and whole-header writes are lost in both emissions equally (byte-parity with the old Ꮡ((Ꮡv.of(…)).Value.Value) form). Go requires identical underlying types for pointer conversions, so the wrapper family closes under .Value reads and single user-defined conversions; anything else (e.g. a named-SLICE reinterpret) keeps the boxed fallback
&T{…} composite literal var ᴛ1 = new T(…);ref ᴛ1 — observationally identical to a distinct heap box, since a lowered callee can never compare, store, escape or convert the address
the literal nil ref ((ж<T>)default!).DerefOrNull() — binds the null ref; the callee’s first use faults with Go’s panic

Address-taken locals revert for free. A local (or value parameter, or named result) whose EVERY address-connected use feeds a lowered position — directly, outside defer/go, outside any closure — loses its heap() box entirely: the declaration reverts to a plain local, removing two counted objects per unmanaged local (the box and its eager pinnable slot). Any surviving box use (a stored address, a closure crossing, a pointer-receiver method) keeps the box, and the lowered sites alias the same storage through the entry ref alias. The reversion also collapses the per-iteration loop-variable boxing scaffold where the loop var’s address only feeds lowered positions (ForVariants).

The nil doctrine (ruling §10.4). Go panics eagerly at &e.x when e is nil — before the callee is entered. A naive null byref would instead let the callee run side effects Go never runs and let a callee recover catch a panic it can never catch (the design review’s S-F1 third behavior). Lowered field/element address formation over a nullable base (a pointer’s deref alias — null exactly when the pointer is nil) is therefore eagerly checked by golib’s nonnil(ref e) — one branch, zero allocation, throwing the exact panic ж<T>.Value raises — and elided where the base provably cannot be null (a value local/parameter/result, an addressed global’s ref property). A plain nil pointer ARGUMENT (f(q) with nil q) still enters the callee and faults at first use, exactly as Go. Measured gc subtlety recorded with the guard: Go evaluates sibling function CALLS among the arguments in lexical order before non-call operands like &e.x, so “later arguments unevaluated” holds only for non-call operands.

defer f(&x) / go f(&x) are boxed sites, categorically. The defer/go machinery stores eagerly-evaluated argument values in a frame, and a managed ref cannot be stored there (the compiling alternative — a copy-box — silently loses writes: the panel’s 0-vs-7 refutation). The eager arguments keep the boxed emission, the statement always takes the temp-param lambda form (a ref-parameter method group cannot convert to Action<…>), and the thunk derives each ref at invoke time: defer(ᴛ1 => setErr(ref ᴛ1.DerefOrNull()), Ꮡerr, ref ᒐ); — preserving Go’s defer-time argument evaluation. An address flowing to a lowered position under defer/go keeps its box (the locals carve-out), and an address-carrying use of a candidate’s OWN parameter inside a defer/go argument frame vetoes that parameter (the X2-defer-arg mirror).

Determinism across emissions: classification reads only the production package’s own files — never _test.go — so the -stdlib and -tests emissions of production sources agree by construction (a white-box export_test.go func-value alias cannot un-lower what -stdlib lowered; unit-guarded).

Landed measured effect on the flagship: crypto/internal/nistec/fiat transpiles with zero heap( sites and zero .of( sites (was 158 address-taken locals and 56 field-ref argument feeds), per the design’s §3.6 projection. (Guarded by the RefLoweredParams behavioral test — write-through, forwarding chains, the mixed kept/reverted local, the defer/go carve-out in all three observable directions, the X5 func-value exclusion — and RefLoweredNilTiming — the eager-panic differential in three nil spellings plus the deferred-fault half, all output-compared against go run. The classifier and its fixed point are unit-guarded in refLoweringAnalysis_test.go; the corpus-wide census instrument is -ref-census.)

The lowered emission, row by row — the seven argument shapes in emitted code

The seven rows of DESIGN-zh-box-reduction.md §3.3, each with its emitted form and the golden that pins it. Every snippet is verbatim from a committed .cs.target, quoted through EXEMPLARS-a2-ref-lowering.md — which carries the before/after pair and the history for each; only the current form is stated here.

# Go argument boxed emission lowered emission golden
1 &e.x — field of a deref’d parameter or receiver (a nullable base) Ꮡe.of(T.Ꮡx) — 1 box ref nonnil(ref e).x RefLoweredParams, GenericReceiverFieldAddress
2 &x — an address-taken local, value parameter or named result Ꮡx, the heap() box minted at the declaration ref x — the plain local; the box and its eager T[1] slot are gone ForVariants
3 a pointer variable/field/deref/assert q — it carries a box q ref (q).DerefOrNull() — read at CALL time, so a re-pointed pointer is never stale PointerParamNilWalk, PointerFieldArrayElementAddress
4 &s[i] / &x.f over a value-rooted base (local, value param, global, slice) Ꮡ(s, i) — 1 box + 1 interface temp; Ꮡx.of(T.Ꮡf) for the field form ref s[i] / ref x.fnonnil elided, the base provably cannot be null AddressOfParamWrite, PointerFieldArrayElementAddress
5 (*T2)(&v.x) — a pointer conversion over a [GoType] named-array wrapper Ꮡ((Ꮡv.of(…)).Value.Value) — 2 boxes hoisted temp: var ᴛ1 = v.x.Value;ref ᴛ1 NamedArrayWrapper
6 a non-variable pointer expression — &T{…}, new(T), any call result Ꮡ(new T(…)) / carries the returned box hoisted temp, same shape as row 5: var ᴛ1 = new T(…);ref ᴛ1 RefLoweredParams
7 the literal nil default! ref ((ж<T>)default!).DerefOrNull() — binds the null ref; the callee faults at first use GuardedNilPointerParamDeref

A lowered parameter forwarded into another lowered position is ref p — it already is the ref. Rows 5–7 share one justification: a lowered callee can never compare, store, escape or convert the address, so a caller-side temporary is observationally identical to a distinct heap box.

Row 1 — the parameter is the alias, and it survives generic instantiation (GenericReceiverFieldAddress; the callee’s ж<T> Ꮡp box and its DerefOrNull() preamble are gone, and the caller’s 128-byte-per-evaluation field box becomes free):

internal static void setT<T>(ref T p, T val) {
    p = val;
}

public static void Set<T>(this ж<Box<T>> b, T val) {
    ref var b = ref b.DerefOrNull();

    setT(ref nonnil(ref b).v, val);
}

Row 2 — an address-taken local comes home from the heap (ForVariants; two counted objects per unmanaged local removed, and the per-iteration boxing scaffold of a labeled loop collapses to one plain loop variable):

nint i = 0;
while (i < 10) {
    f(ref i);
    i++;
}
internal static void f(ref nint y) {
    fmt.Print(y);
}

Row 3 — the callee lowers, the call site unwraps (PointerFieldArrayElementAddress; c comes from .at(…) indexing and so still carries a box — each function makes its own deal and the convention change composes across the boundary):

internal static void bump(ref cycle c) {
    c.n++;
}
internal static void viaParam(ж<rec> p, nint i) {
    var c = p.at(rec.future, i);
    bump(ref (c).DerefOrNull());
}

The same row, dereferenced per call rather than bound once, is what keeps a reassigned pointer honest (PointerParamNilWalk, whose walk loop emits advance(ref (Ꮡp).DerefOrNull())); note also what does not lower there — a pointer escaping through a return keeps its box identity, so advance’s (ж<node>, nint) result is unchanged.

Row 5 — two boxes become one temp (NamedArrayWrapper; the wrapper’s Value yields an array<T> header whose T[] backing is SHARED, so element writes flow through and whole-header writes are lost in both emissions equally — byte-parity, not a new behavior. Type-gated by refConvPairingSupported to the identical-underlying-array family; a string or numeric wrapper’s value is a plain copy and keeps its identity box end to end):

scal sm = new();
var 1 = sm.s.Value;
fromBytes(ref 1, 7);
var 2 = (nonMont)((sm.s).Value);
@double(ref sm.s, ref 2);

Row 7 — a lowered parameter still accepts Go’s nil (GuardedNilPointerParamDeref; the synthesized argument binds a null box and defers the fault to the first actual use inside the callee, which is Go’s “a nil pointer only panics when dereferenced” timing. RefLoweredNilTiming pins it against go run):

internal static nint digits(nint @base, ref nint invalid) {
    ...
}
nint c2 = digits(10, ref ((ж<nint>)default!).DerefOrNull());

The counter-examples are guarded beside the lowered ones (RefLoweredParams), so the boundary is itself under test: a parameter compared to nil keeps its box (its identity is observed); one used as a func value keeps it (a method group cannot close over a ref); a defer/go site keeps it and derives the ref at invoke time (defer(ᴛ1 => bump(ref ᴛ1.DerefOrNull()), Ꮡresult, ref ᒐ);); in-lambda call sites are uniformly boxed-fallback wrapped .DerefOrNull(); string/numeric wrapper reinterprets sit outside row 5’s family; and a blank or unnamed pointer parameter is never a candidate.

Pointer-typed globals and double-pointer walks (&head, *pp, ValueSlot)

A package-level global of pointer type whose address is taken — var head *node with pp := &head — is heap-boxed like any addressed global, yielding a double box: ж<ж<node>> Ꮡhead. Three rules make the classic linked-list walk (for pp := &head; *pp != nil; pp = &(*pp).next { … *pp = n }) faithful:

  1. One star is ONE deref. *pp on a **T yields a *T — a single .Value/.ValueSlot hop, never two. (An older arm added an extra .Value per pointer depth, double-dereferencing every single-star of a double-pointer field — runtime mheap.go’s specialsIter walk failed CS0029 in both assignment directions.) A genuine **pp is two nested StarExprs, each contributing its own hop. Likewise a field read through an explicit single star on a **T(*outer.ptr).Value — keeps the base pointer-typed after one star, so normal pointer-base field handling supplies the remaining auto-deref: (~(outer.ptr.Value)).Value.
  2. A deref whose result is still reference-like reads ValueSlot, not Value. Go’s *pp may legally yield nil (*pp != nil is the loop condition); only dereferencing that nil panics. golib’s strict Value accessor nil-checks the slot, so a deref (or boxed-global property) producing a pointer/slice/map/chan/func/interface value routes through ж<T>.ValueSlot — the identical real slot with no nil check — and reads and writes both persist: pp.ValueSlot = n lands in the original global storage. A deref producing a plain value keeps the strict Value (a nil *node deref must panic, as in Go). The boxed global’s ref-property follows the same split: internal static ref ж<node> head => ref Ꮡhead.ValueSlot; for the pointer-typed global, => ref Ꮡg.Value; for a value-typed one.
  3. &global on an addressed global is the identity box, never a copy. &allm (where var allm *m is boxed) emits Ꮡallm — the existing box — not Ꮡ(allm), which would heap-allocate a copy and silently disconnect writes. And &(*pprev).alllink (address of a field behind one explicit star) peels the star and goes through the field-box accessor: pprev.Value.of(m.Ꮡalllink).

The full emitted walk:

internal static ж<ж<node>> head = new(default(ж<node>));
internal static ref ж<node> head => ref head.ValueSlot;

for (var pp = head; pp.ValueSlot != nil; pp = (pp.ValueSlot).of(node.next)) {
    if ((~(pp.ValueSlot)).val == v) {
        pp.ValueSlot = (pp.ValueSlot).Value.next;   // *pp = (*pp).next — write lands in real storage
        ...

This is exactly the runtime’s allm/itabTable shape (for pprev := &allm; *pprev != nil; pprev = &(*pprev).alllink). (Guarded by the GlobalPointerWalk behavioral test — ordered insertion, head/middle removal, and a method call through the pointer global, all via **node writes, output-compared against Go.)

Capturing the address of a heap-boxed local in a closure

A local whose address is taken (&m) is heap-boxed: the converter emits ref var m = ref heap(new T(), out var Ꮡm), where Ꮡm is the box and m is a ref-local alias of Ꮡm.Value. When a function literal captures such a local and takes its address inside the closure, the variable must be referenced through the box, not snapshot-copied. A C# ref-local cannot be captured by a lambda (CS8175), and the older snapshot capture (var mʗ1 = m;) is wrong twice over: it copies the value out of the box (so writes through the captured &m are lost), and the copy declaration is a statement that has nowhere valid to land when the literal sits in an expression position — e.g. a func literal passed as a call argument (run(func(){ use(&m) })) or a local initializer (f := func(){ use(&m) }).

The fix: a heap-boxed local whose address is taken inside a lambda is marked box-ref and the snapshot is suppressed. The box Ꮡm is a plain local (a capturable reference), so the C# closure captures it by reference — matching Go’s capture-by-reference semantics. Inside the closure the converter then renders every form through the box:

ref var m = ref heap(new box(), out var m);
run(() => {
    set(m);                       // &m  → Ꮡm
    m.Value.y = m.Value.x + 1;       // value use of m → Ꮡm.Value
});
// &m.field (value struct field) → Ꮡm.of(box.Ꮡfield)

This also covers &m.field (a value-struct field address inside the closure: Ꮡm.of(box.Ꮡfield)). The detection is scoped to the bare &m and value-struct &m.field forms (the ones with a box-ref emission form); an element address &m[i] keeps the existing snapshot path. The behavioral test FuncLitArgCapture guards the call-argument, value-use, field-address, and initializer cases.

A capture that is WRITTEN after the capture point routes to shared storage, not a snapshot

Go closures share the ONE variable with the enclosing function. The value snapshot the converter uses for captured structs/arrays/slices/maps/chans (var tʗ1 = t; hoisted before the lambda, in-lambda references renamed to tʗ1) is therefore only observationally correct while neither side writes the variable after the snapshot point. Once anything does, the snapshot silently diverges — the program compiles and runs, with wrong values:

The converter now detects written-after-capture per variable during analysis (varShareFacts, one cached scan of the enclosing declaration) and routes such captures to shared storage. Writes counted, conservatively by syntax: an assignment or ++/-- whose target roots at the variable’s own storage (t = …, t.f.g = …, array a[i] = … — but not through a deref, an implicit pointer deref p.f = …, or a slice/map element, which a snapshot copy shares anyway); a pointer-receiver method call on the variable held as a value (Go’s implicit &t); a for t = range clause; an explicit &t anywhere or an uncalled pointer-receiver method value (an alias — later writes through it are syntactically invisible, so it counts at any position, e.g. p := &t; get := func(){…}; p.total = 50); and any of these inside any func literal (the literal may run at any time). A plain body write counts only if positioned after a referencing literal or sharing a for/range loop with one (a later iteration’s write follows an earlier iteration’s creation).

The routing, by variable shape:

// Heap-boxed variable (escaping struct local, aliased int, …) → by-box (boxRefVars):
ref var t = ref heap<Tally>(out var t);
t = new Tally(5, "s");
var bump = () => {
    t.Value.total += 100;      // value use → Ꮡt.Value: writes the ONE box the body reads
};

// Unboxed variable (value parameter; slice/map/chan local, whose copy diverges on
// reassignment) → NATIVE C# capture — no snapshot, no rename; the display class
// shares the local exactly as Go shares the variable:
internal static void probeB1(Tally t) {
    var bump = () => {
        t.total += 100;          // captures the parameter itself
    };
    bump();
    t.total++;                   // 106, matching Go

This applies only to genuine closure-body references (a func literal’s body, directly or as a go/defer statement’s literal callee). A go/defer statement’s non-literal callee/receiver expression and its call arguments keep their statement-time evaluation — defer fmt.Println(t.total) still prints the registration-time value, which IS Go’s argument semantics. Read-only-after-capture variables keep the snapshot (observationally identical, zero churn — the vast majority of stdlib captures). A loop-statement-defined variable (for-init or range clause) also keeps it: its per-lambda snapshot approximates Go 1.22’s per-iteration variable, which shared routing would break. A literal’s own parameters/results are not captures and are excluded. (The remaining known gap, deliberately out of scope here: a for-init variable captured by closures diverges from Go 1.22 per-iteration semantics — the C# for control variable is shared across iterations — tracked as its own defect.)

The native route makes the emitted C# read exactly like the Go for the parameter case; the by-box route reuses the box-ref machinery above (including ValueSlot for inherently-heap locals: a captured slice that the closure reassigns emits Ꮡs.ValueSlot = append(Ꮡs.ValueSlot, …) against a materialized heap<slice<T>> box). (Guarded by the ClosureWriteVisibility behavioral test — 19 probes: boxed/plain × local/param × closure-writes/body-writes-after × plain/defer/go/IIFE/loop-created contexts, plus slice/map reassignment, alias writes, two-closure sharing, and the read-only/defer-argument/range controls that must KEEP snapshot semantics.)

A NAMED RESULT routed to shared storage declares its box too. The defer func(){ hook(written, err) }() idiom is exactly the written-after-capture shape above with the captured variable being a named result — Go’s deferred closure must observe the FINAL named-result values. When the escape analysis marks such a result (an interface-typed result is blanket-marked the first time it is reused on a mixed v, err := … define; a value-type one when &x is taken), the render sites duly go through the box (Ꮡerr.ValueSlot inside the deferred literal) — but the named-result declaration prologue emitted only the plain error err = default!;, leaving Ꮡerr undeclared (CS0103 — internal/poll SendFile’s deferred TestHookDidSendFile, the single error skip-cascading ~80 os-dependent packages). A box-backed named result (identHasHeapBox, the same gate plain locals use) now declares the box, in three shapes:

The box-read accessor follows the box-ref rule above: .ValueSlot for an inherently-heap result (reading the held reference is not a dereference), .Value for a value-type box. Results NOT escape-marked are untouched — written in the same defer stays a plain local captured natively by the C# closure, which already observes the final value. (Guarded by the NamedResultDeferCapture behavioral test — value + error named results logged by a deferred closure with post-capture writes and bare returns, the &x value-result, the func-literal sibling, and a non-defer closure write; output-compared vs Go, proving the deferred observation of FINAL values. Stdlib footprint: 12 functions across 10 files — internal/poll, net/http, go/parser, crypto/tls, internal/fuzz, debug/buildinfo, both go importers, net/textproto.)

A PARAMETER routed to shared storage declares its box too — the third position of the same family (plain locals, named results, parameters). A parameter can be escape-marked without any capture-mode method call: a body-top-level mixed := REDECLARES the parameter object (the spec’s redeclaration rule includes the parameter lists when the block is the function body), so the define walker escape-analyzes it — and an interface-typed one is blanket-marked. When such a parameter is also captured by a closure and written after the capture point, the routing above sends it by-box (Ꮡctx.ValueSlot inside the lambda) — but the parameter prologue only boxed for the capture-mode (direct-ж) trigger, leaving the box undeclared (CS0103): database/sql beginDC’s ctx (redeclared by ctx, cancel := context.WithCancel(ctx) after withLock’s closure captured it) and go/types nify’s x, y (swapped by x, y = y, x and redeclared by xorig, x := x, Unalias(x) after the trace defer captured them). paramNeedsHeapBox (and its func-literal analogue funcLitHeapBoxParamIdents) now also fires for a box-ref-routed parameter, emitting the exact capture-mode form — the signature takes the incoming value as ctxʗp and the preamble declares ref var ctx = ref heap(ctxʗp, out var Ꮡctx); (inside the frame’s try when the function has a frame, where the box is an ordinary capturable local). Body statements keep reading/writing the plain ref alias — the redeclare emits (ctx, var cancel) = … against it — so both sides hit the ONE box, and a deferred observer sees Go’s FINAL values. The check rides the declaring-ident lookups, so a box-ref’d value RECEIVER (never ʗp-renamed by the signature paths) can never take the param form. (Guarded by the WrittenCaptureParam behavioral test — the beginDC redeclare shape, the nify deferred-observer shape (named result + defer frame), a closure-write read back by the body, the func-literal sibling, and an inherently-heap slice param; all output-compared vs Go. Stdlib footprint: exactly database/sql/sql.cs + go/types/unify.cs.)

A write that ENCLOSES the literal counts as written-after-capture — the self-recursive closure

The write scan above compares positions: a body write counts when it sits after a referencing literal, or shares a loop with one. Go’s standard recursive-closure idiom defeats a pure position test, because the write starts before the literal it contains:

var check func(uint32, []bool) bool
check = func(pc uint32, m []bool) (ok bool) {
	
	ok = check(inst.Out, m) && check(inst.Arg, m)   // recurses through the variable
	
}

The assignment statement’s position is that of check on its left, which precedes the literal on its right — yet the RHS is evaluated first, so the store to check unambiguously happens after the literal exists. Scored as read-only-after-capture, the capture took the snapshot path (var checkʗ1 = check; hoisted above the assignment) and every recursive call invoked the still-null delegate: a NullReferenceException on the first recursion. In regexp’s makeOnePass that is the entire ambiguity check, so ^.$ — and most of the package — became uncompilable. The scan now also counts a write whose syntactic extent contains a referencing literal (w.pos < lit.pos < w.end), which routes the capture to shared storage; check escapes, so it takes the by-box form and the recursion resolves against the box the assignment fills:

ref var check = ref heap<Func<uint32, slice<bool>, bool>>(out var check);
check = (uint32 pc, slice<bool> mΔ1) => {
    
    ok = check.ValueSlot((~inst).Out, mΔ1) && check.ValueSlot((~inst).Arg, mΔ1);
    
};

The same edge covers mutually recursive closures (even/odd, each literal enclosed by the write to its own name while reading the other), and it generalizes beyond closures: any write that evaluates a referencing literal as part of itself — t.mutate(func(){ use(t) }) — now counts. (Guarded by the ClosureWriteVisibility probes Q1/Q2 — a self-recursive sum and a mutually recursive parity pair; the pre-fix converter compiles both and nil-derefs at the first recursive call.)

A nested closure’s capture snapshot reads the enclosing closure’s snapshot

When a heap-boxed ref-local is used by VALUE (its address is not taken) and captured by NESTED closures, it is not box-ref’d — it is snapshot-copied: the converter declares var mʗ1 = m; before the closure and the closure uses mʗ1, so the uncapturable ref-local m is never referenced inside the lambda. The snapshot chain must be threaded through each level. A capture generated for an inner closure that lands inside an outer closure’s body must read the outer closure’s snapshot, not the enclosing method’s ref-local — the shape testing/fuzz.go’s run closure has, capturing fn := reflect.ValueOf(ff) (a heap-boxed reflect.Value) and spawning go tRunner(t, func(t){ … fn.Call(args) }) from inside itself, where the method-level fn is a ref-local uncapturable inside a closure (CS8175). The guard’s own shape emits it:

ref var p = ref heap<payload>(out var p);
p = new payload(vals: new nint[]{1, 2, 3, 4}.slice());
var @out = new channel<nint>(1);
var outʗ1 = @out;
var pʗ1 = p;                   // outer's snapshot (before the outer closure)
void outer() {
    var outʗ2 = outʗ1;
    var pʗ2 = pʗ1;             // the goroutine's snapshot reads outer's pʗ1, NOT p
    goǃ(() => {
        outʗ2.ᐸꟷ(pʗ2.sum());
    });
}

generateCaptureDeclarations finds the RHS by walking the conversion stack outward past pass-through levels (a go/defer statement’s own enterLambdaConversion, which carries an empty rename map) to the first enclosing lambda that renamed the variable. It skips the capture’s OWN owner state — pendingCaptures is shared across a function’s lambdas, so an outer lambda’s snapshot can be generated while converting an inner func-literal argument (go dnsWaitGroupDone(ch, func(){}), net/lookup.go), leaving the owner’s state on the stack with a rename equal to the name being declared; adopting it would emit a self-reference var fʗ1 = fʗ1; (CS0841). Byte-identical corpus-wide except where a nested closure re-captures a heap-boxed local. Guarded by FuncLitArgCapture (a heap-boxed struct re-captured in an inner goroutine — CS8175 without the fix — and the go f(x, func(){}) self-reference shape) and by DeferValueFieldPtrReceiver (a defer inside a lambda).

A pointer (or other inherently-heap) local captured by a closure that takes its address needs the box too, but reaches it by a different route. A local of an inherently heap-allocated type — a pointer, slice, map, channel, interface, or func — is already a reference, so it normally gets no heap box (the convertToHeapTypeDecl path returns nothing for such types). But when one is captured by a closure that takes its address (mToFlush := &node{…}; run(func(){ prev := &mToFlush; … *prev = mToFlush.next })), the closure needs a shared box so writes through &mToFlush inside it reach the outer function’s storage. The converter detects this as the same box-ref mark used above (an inherently-heap local whose address is taken inside a lambda), and for a box-ref local it now emits the heap box even though the type is inherently heap — ref var mToFlush = ref heap<ж<node>>(out var ᏑmToFlush) — so the box ᏑmToFlush (a ж<ж<node>>, i.e. a **node) exists for the closure to reference. Without it the closure emitted ᏑmToFlush for &mToFlush against a never-declared box (CS0103); a same-function &ptr with no closure still takes the Ꮡ(ptr) copy form (a copy is fine there — no shared storage is needed), so that case is unchanged.

Reading such a box needs care, because for a box-of-pointer the held value can legitimately be nil while the box itself is a real allocation. Ꮡm here is a ж<ж<node>> (a **node), so Ꮡm.Value reads the held pointer value — not a dereference of Ꮡm — and in Go reading *(&p) when p is a nil *T/slice/map yields the nil value, with no dereference and no panic. The strict ж<T>.Value getter (which panics on a null stored value by design, so a genuine *p on a nil pointer still throws) would wrongly panic on that read. So the converter emits the golib ж<T>.ValueSlot accessor for these box-of-pointer reads — identical to .Value but without the nil-pointer-dereference check, returning the real slot so reads and writes both persist (and unlike the retired DerefOrNil, which yielded a throwaway slot for a genuinely-nil box). ValueSlot is selected here for a box-ref local of inherently-heap type; a deref’d pointer parameter reaches the same slot through DerefOrNull(), whose non-nil path IS ValueSlot — see The THREE deref accessors of ж<T>. The heap(out …) / heap(target, out …) helpers likewise return ref pointer.ValueSlot: a freshly allocated box is structurally non-nil, so the getter’s nil check there is always spurious (identical to .Value for a value-type box; it just avoids a spurious panic when establishing the ref var mToFlush = ref heap<ж<node>>(out var ᏑmToFlush) alias). A genuine dereference of the held pointer (the second .Value in ᏑmToFlush.ValueSlot.Value.v) stays strict and still panics on nil — preserving Go’s “panic ⇒ panic” semantics, and complementing the deliberate strict-.Value design at every genuine USE site. (Guarded by the ClosureCapturedPointerAddress behavioral test — a closure that takes the address of a captured pointer local, walks a linked list by reassigning through that address and mutating each node, with the outer function observing both the reassignment-to-nil and the persisted mutations, proving the box is shared rather than copied. Mirrors runtime’s trace.go mToFlush := allm; systemstack(func(){ prev := &mToFlush; … mToFlush = mToFlush.next }), ~4 CS0103.)

A pointer-receiver method called through a FIELD of such a boxed pointer local, inside the closure, field-refs through the held pointer, not the box. The receiver of c.flushGen.Store(…) (runtime mcache.go’s allocmcache, inside systemstack) is taken via the &-machinery, and inside a lambda the box-ref address form substitutes the capturable box for the uncapturable ref-local alias. For a value-struct local (box ж<T>) and a deref’d pointer parameter (box ж<T> — the Go pointer itself) the bare box is the correct .of() receiver — but a boxed pointer LOCAL’s box is ж<ж<T>>, one level above the ж<T> the field accessor projects from, and feeding it to .of fails inference (CS0411 — the one error that skip-cascaded ~237 packages behind runtime). Such a base declines the bare-box form and falls through to the pointer-variable field arm, whose ident render reads the box the same way every other in-lambda value use does: Ꮡc.ValueSlot.of(mcache.ᏑflushGen).Store(…).ValueSlot because reading the held pointer out of the box must not nil-check (the dereference happens in .of, preserving panic semantics), and because that slot IS what the enclosing ref var c = ref heap<ж<mcache>>(out var Ꮡc) alias reads. When such a local is named after its own type (gauge := newGauge()), the accessor’s owning-type name additionally qualifies with the package class (Ꮡgauge.ValueSlot.of(main_package.gauge.Ꮡv)): the enclosing ж<gauge>-declared local stays visible inside the lambda, so the bare type name binds the uncapturable ref-local (CS8175) with no identical-simple-name fallback — the declared type differs from the type name. (Guarded by the ClosurePtrLocalFieldMethod behavioral test — the allocmcache shape: a pointer local written inside a closure and immediately method-called through a value field, read back after the closure, plus the named-after-type variant; output-compared vs Go, proving the write-through and the field-method call both bind the one shared box.)

A deref’d pointer parameter or pointer receiver captured by a closure is box-ref’d the same way, even when only its value is used inside the closure (not its address). Such a parameter is emitted as the box ж<T> Ꮡp with ref var p = ref Ꮡp.DerefOrNull(), and the ref-local alias cannot be captured (CS8175). Inside the closure a value use becomes Ꮡp.Value.field and an address use Ꮡp, so the closure captures the box by reference — matching Go capturing the pointer. (Guarded by the behavioral test PointerParamCapturedInClosure; the runtime captures *maptype / *m parameters this way pervasively.)

A pointer receiver captured by a closure needs an extra step the parameter case does not: the box Ꮡp only exists if the method is emitted direct-ж (the box passed as the receiver, this ж<T> Ꮡp). A normal pointer-receiver method is [GoRecv] this ref T p (a value-ref receiver, with the ж<T> companion generated separately), which has no box for the closure to reference. So “the receiver is referenced inside a function literal” is a direct-ж trigger — a fourth one alongside taking a field’s address (&p.field), returning the receiver (return p), and using the receiver as a bare pointer value (p.next = p, p != q). Mirrors runtime’s func (p *_panic) nextFrame() { systemstack(func(){ … p.lr … }) }. A closure parameter that shadows the receiver name resolves to a distinct object, so it does not falsely trigger the promotion. (Guarded by the ReceiverCapturedInClosure behavioral test — receiver captured by an immediately-invoked closure that reads/writes through it, by one that takes a field’s address, and by one that is returned so the box must outlive the call.)

Once a method is direct-ж, its receiver is the box Ꮡc, but the deref’d value alias ref var c = ref Ꮡc.DerefOrNull() is what most uses see. When such a receiver is passed whole as a pointer argument — stackcache_clear(c) in func (c *mcache) prepareForSweep() — the argument must be the box Ꮡc, not the value alias c (a value cannot bind a ж<mcache> parameter → CS1503). A deref-aliased pointer parameter is already handled (it is an identIsParameter), but a direct-ж receiver is not a parameter, so the call-argument conversion recognizes it explicitly and emits the box. (Guarded by the DirectBoxReceiverPassedWhole behavioral test.)

The receiver placed whole into a composite-literal element whose field is a pointer — func (f *_func) funcInfo() funcInfo { …; return funcInfo{f, mod} } (runtime symtab.go; funcInfo’s first field is the embedded *_func) — needs the same box, and is itself a direct-ж promotion trigger (bodyUsesReceiverAsPointerValue’s composite arm): a boxless [GoRecv] ref receiver has no Ꮡf to place in the field (CS1503). Once promoted, the composite renders the box through the existing pointer-field element machinery: new ΔfuncInfo(Ꮡf, mod). Both positional and keyed elements trigger, gated on the field’s declared type being a Go pointer (resolved positionally or by key from the composite’s struct type — the element expression’s own type is always *T for a pointer receiver): a receiver placed into an interface-typed field also typechecks in Go, but that emission compiles today, and promoting for it would re-route every such method stdlib-wide (the field gate trims the first-cut 73-file audit to 68 — the shape is genuinely pervasive: go/types’ Checker methods, net/textproto’s dotReader{r: r}, zstd readers — every audited site the same signature+box re-routing) — its pointer-identity semantics are logged as a separate question. (Guarded by the DirectBoxReceiverPassedWhole extension — positional + keyed composites, identity verified by writing through the wrapped pointer and reading the original.)

The same composite arm also fires when the receiver is stored as an element of a SLICE or ARRAY literal whose element type is a pointerfunc (s *UserTaskSummary) Descendents() []*UserTaskSummary { descendents := []*UserTaskSummary{s}; … } (internal/trace summary.go). Without promotion the boxless [GoRecv] ref receiver renders the value alias s into a ж<T>[] slot (CS0029); once promoted direct-ж, the element renders the box: new ж<UserTaskSummary>[]{Ꮡs}.slice() (and [2]*T{s, other}new ж<T>[]{Ꮡs, Ꮡother}.array()). Gated on the slice/array element type being a pointer (the *types.Slice/*types.Array arms of bodyUsesReceiverAsPointerValue), mirroring the struct-field pointer gate. (Guarded by the ReceiverPointerValue extension — the receiver stored into a []*ring and a [2]*ring literal, chain[0] identity verified by mutating through the stored pointer and reading back through the receiver.)

The same pointer-element boxing must also fire for an ELIDED (type-inferred) nested composite — the inner {c} of [][]*Certificate{{c}} (crypto/x509 Verify). The inner literal has no Type node; its inferred element type is *Certificate, and its sole element c is the deref-aliased *Certificate receiver. The typed composite path boxes a bare pointer-typed ident element (argTypeIsPtr), but the untyped-elided slice/array path rendered its elements with a nil context, so that treatment never ran and c emitted the value alias into a ж<Certificate>[] array (CS0029). The elided path now supplies a context that boxes a bare pointer-typed ident when the element type is a pointer — new ж<Certificate>[]{Ꮡc}.slice() — returning nil (unchanged nil-context rendering) when the element type is not a pointer or no element is a bare pointer ident, so non-pointer elided literals stay byte-identical. (Guarded by the ElidedNestedPtrComposite behavioral test — [][]*Node{{n}} where n is a pointer receiver.)

A MAP composite literal whose value or key type is a pointer boxes its element the same way — but through convKeyValueExpr (the [key] = value form), not the slice/array element loop above. map[K]*T{k: c} where c is a deref’d pointer parameter renders the value alias c into a ж<T> map slot (CS0029); the map-source branch of convKeyValueExpr now sets the isPointer ident context for the VALUE when the map’s declared element type is a pointer, so a bare-ident pointer value emits its box Ꮡcnew map<@string, ж<node>>{["a"u8] = Ꮡa}. A pointer-KEY map (map[*T]V{c: 1}) boxes the key the same way (new map<ж<node>, nint>{[Ꮡa] = 1} — the ж<T> dictionary key matches by box identity). Gated on the map’s declared element/key type being a pointer (not an interface — an interface-valued map still routes through the interface conversion) and the element expr’s own type being a pointer, so a value already rendered as a box (&x, a pointer local) is unaffected. (Guarded by the MapPointerElementLiteral behavioral test — a pointer-value map and a pointer-key map built from *node parameters, aliasing verified by mutating through a stored value and looking up by pointer-key identity.)

Reassigning a pointer parameter to a new pointer. A *T parameter that walks memory by reassignment — bits = addb(bits, n) (a *byte step in the runtime’s bitmap scanners) or p = p.next (a list walk) — cannot write through its value alias: ref var bits = ref Ꮡbits.Value makes bits the pointed-to value, and a pointer RHS (ж<byte>) does not fit it (CS0266/CS0029). The reassignment instead repoints the box and re-aliases the value var — Ꮡbits = addb(Ꮡbits, n); bits = ref Ꮡbits.Value; — reusing the same box-reassignment path that handles a direct-ж receiver’s r = r.prev (the RHS already emits the box form). (Guarded by the PointerParamWalk behavioral test, a circular-list walk that reassigns the parameter and reads the pointed-to value each step.) Reassigning a pointer local (not a parameter) is unaffected — a local already holds the box.

Reassigning a captured pointer parameter inside a closure. The repoint-and-re-alias above (Ꮡp = …; p = ref Ꮡp.Value;) rebinds a ref-local. Inside a CLOSURE that captured the parameter that is illegal: the re-aliased value var is an ENCLOSING ref-local, and C# forbids referencing an outer ref local inside a lambda (CS8175 — crypto/x509 buildChains’s considerCandidate closure does if sigChecks == nil { sigChecks = new(int) } on the captured *int parameter). The box reassignment Ꮡp = … is legal (it writes the captured box field, hoisted to a closure field), so only the ref-local refresh is dropped inside a lambda:

if (sigChecks == nil) {
    sigChecks = @new<nint>();          // was: … ; sigChecks = ref ᏑsigChecks.DerefOrNull();  (CS8175)
}
sigChecks.Value++;

Every in-lambda and post-lambda dereference of a repointed captured pointer routes through the box Ꮡp.Value, so the now-stale value alias is never read — an accepted modeling gap (like the nil-terminated walk’s), not a miscompile. The suppression is sound because no LEGITIMATE re-alias ever occurs inside a lambda: a lambda’s OWN pointer parameter is passed as the box ж<T> (never deref-aliased), and a heap-boxed value local is written THROUGH its box (Ꮡb.Value = …, never box-repointed). Guarded by ClosureReassignsPtrParam (a closure that reassigns a captured *int parameter; a non-nil runtime argument keeps the reassignment branch unreached so output stays deterministic).

The same repoint-and-re-alias applies when the parameter is reassigned from a tuple(left, x, idx) = binarySearchTree(x, idx, n/2) (runtime mgcstack.go) or pp, _ = pidleget(0) (proc.go). The box-reassignment triggers matched the RHS element-wise, so a tuple deconstruction (one call RHS, several LHS) never fired them — the ж tuple component was assigned into the deref'd value alias (CS0029) — and element 0's raw expression type is the whole `*types.Tuple` (never a pointer), so even a first-position pointer element missed. The per-element RHS type now comes from the call's result tuple, and the emitted form is the single-assign form verbatim: `(left, Ꮡx, idx) = binarySearchTree(Ꮡx, idx, n / 2); x = ref Ꮡx.DerefOrNull();` — the same nil-deferring re-alias every repoint takes (`(Ꮡpp, _) = pidleget(0); pp = ref Ꮡpp.DerefOrNull();`). The triggers are gated to a **reassigned** element: a `:=`-declared pointer element binds the tuple's ж component into a fresh pointer local — which *is* the box — directly, and an inner `:=` local shadowing a parameter's name must not repoint the parameter's box (crypto/x509's `c, _, err := …cert(i)`). (Guarded by the `PointerParamNilWalk` extension — a nil-compared tuple-reassign walk plus a reassign-then-mutate-through probe, values vs Go.)

Assigning nil to the parameter itself is a box repoint, not a write to the pointee. Both triggers above gate on the RHS being pointer-typed, and the untyped nil literal has no type of its own — so p = nil missed them, rendered against the deref’d value alias, and emitted p = default!, which zeroes the pointed-to struct while leaving the box Ꮡp non-nil. The caller’s != nil then still passed and it walked a wiped-out object. This is regexp’s makeOnePass, whose p = nil (the “not one-pass after all” bail-out) handed compileOnePass an onePassProg with an emptied Inst slice instead of a nil pointer — an index-out-of-range in cleanupOnePass on every pattern the one-pass analysis rejected, which is most of them. A nil RHS is now treated as pointer-valued whenever the corresponding target is pointer-typed, so it takes the ordinary repoint-and-re-alias form (nil-DEFERRING, as every repoint is):

// regexp/onepass.go — makeOnePass
if !check(pc, m) { p = nil; break }

if p != nil { for i := range p.Inst { p.Inst[i].Rune = onePassRunes[i] } }
if (!check(pc, m)) {
    p = default!; p = ref p.DerefOrNull();  // the POINTER goes nil; the pointee is untouched
    break;
}

if (p != nil) { foreach (var (i, _) in p.Inst) { p.Inst[i].Rune = onePassRunes[i]; } }

A pointer local assigned nil is unaffected — a local already is the box, so p = default! is correct there. (Guarded by the PointerParamNilWalk extension dropIfShort — nils the parameter, returns it, and the caller then proves the original node’s value survived; the pre-fix converter compiles it and reports a non-nil result with a zeroed pointee.)

Nil-terminated walk. A pointer-parameter walk that stops at a nil terminator — func sumList(p *node) int { for p != nil { total += p.val; p = p.next } } — needs two extra pieces, modeled together:

  1. Compare the box, not the value alias. The loop guard p != nil must emit Ꮡp != nil (the box). Each binary operand’s pointer context is otherwise taken from the other operand’s pointer-ness, and nil is not a pointer type — so the param would convert in value form (p != nil, comparing a node struct value, the wrong thing). The converter forces the box form for a deref’d pointer parameter in a ==/!= comparison. This is safe only for a parameter: a pointer local is already the box, and forcing it would emit a non-existent Ꮡlocal.
  2. Nil-deferring re-alias. On the final step p.next is nil, so Ꮡp = p.next repoints the box to nil; re-aliasing through the plain Ꮡp.Value getter would then throw a nil-pointer dereference before the guard is re-checked. The deref/re-alias instead routes through the golib ж<T> extension Ꮡp.DerefOrNull(), which binds Unsafe.NullRef<T> when the box is nil — legal to HOLD, faulting only on USE — rather than throwing at the bind. The entry alias uses it too, so an empty-list call (sumList(nil)) binds without faulting at entry.
internal static nint sumList(ж<node> p) {
    ref var p = ref p.DerefOrNull();
    nint total = 0;
    while (p != nil) {
        total += p.val;
        p = p.next; p = ref p.DerefOrNull();
    }
    return total;
}

DerefOrNull() is not a substitute for a genuine dereference: reading or writing *p on a nil pointer (~Ꮡp / Ꮡp.Value) still panics, preserving Go semantics — and so does a read THROUGH the bound null ref, which is the whole point. Neither piece needs a predicate any more: piece 1 (the box-form comparison) is a property of the expression, and piece 2 is what EVERY pointer entry alias and repoint now emits, because a repoint is not a dereference in Go and a nil argument is not an error in Go (see A pointer PARAMETER is nil-deferring for exactly the reason a receiver is). Historically both were gated on the body nil-COMPARING the parameter, which covered the reassigned walk above and a nil-testing body invoked with a literal-nil argument (defer closeIt(nil, 3)p == nil) — at the cost of a shared default(T) slot that let an unguarded deref of an actually-nil argument read a silent zero where Go panics. The unconditional accessor keeps the walk working and drops the trade. (Guarded by the PointerParamNilWalk behavioral test — a nil-terminated sum, a mutate-through-the-parameter pass, and an empty-list call — plus DeferTypelessReturns’ deferred nil-argument call. PointerParamWalk covers the never-nil circular walk.)

A package-level global referenced inside a closure is not captured at all — it is a C# static, accessed live. A value snapshot (var gʗ1 = g) would copy the struct (so &gʗ1 has no box → CS0103, and writes through the global from inside the closure would be lost) and is semantically wrong, since Go reads/writes the live global. For an address-taken (heap-boxed) global the closure references the static box Ꮡg directly — a method call routes as Ꮡg.method() and a field address as Ꮡg.of(T.Ꮡfield). (Guarded by GlobalCapturedInClosure; the runtime does this in every systemstack(func(){ … mheap_ … }).)

A func literal that is only ever CALLED emits as a C# LOCAL FUNCTION

A C# lambda that captures anything allocates two heap objects every time the lambda expression is evaluated: a display class holding the captured variables, and a delegate bound to it. That is charged per call of the enclosing function, whether or not the closure is ever invoked — 88 bytes for the two-word case, measured. Go allocates neither when its escape analysis proves the closure does not outlive the frame, which is why time’s TestUnmarshalTextAllocations asserts want 0 allocs, and why parseRFC3339’s parseUint := func(…) was 88 of that row’s 216.

A name := func(…){…} whose variable is only ever the callee of a call is therefore emitted as a C# local function instead:

//  Go:   ok := true
//        parseUint := func(s bytes, min, max int) (x int) { … ok = false … return x }
var ok = true;
nint /*x*/ parseUint(bytes sΔ1, nint minΔ1, nint max) {
    nint x = default!;
    
    ok = false;          // the SAME `ok` — both sites are rewritten to one struct-closure field
    
    return x;
}
nint year = parseUint(((bytes)(s[0..4])), 0, 9999);

Roslyn compiles a local function that is never converted to a delegate with a by-ref struct closure: the captured variables move into a struct that lives in the enclosing frame and is passed as a hidden ref parameter. There is still exactly one storage location per captured variable — the enclosing method’s own uses are rewritten to the same field — so sharing, write-visibility and the capture-snapshot machinery are all unchanged. Only the heap objects are gone. The result type is rendered by the same helper visitFuncDecl uses, so a named Go result keeps its /*x*/ comment and a local function reads exactly like a declared one; a single-return literal keeps the expression-bodied collapse (byte num2(slice<byte> bΔ1) => …;).

The “only ever called” proof is what keeps that compilation available, not a convenience: converting a local function to a delegate anywhere makes Roslyn fall back to a heap display class, and a local function has no value form to give a store, a return, an argument or a comparison in the first place. Every reference other than the declaring occurrence must be a call callee — which also subsumes reassignment (f = … is a non-call use of f) and address-taking, so the emitted name can never be required as a first-class value. Three further gates: the statement must be a := define with one LHS ident and one RHS literal (a mixed f, err := … re-use records the name in Uses, not Defs, and binds no fresh object); it must be in statement position, since a local function is a declaration and cannot sit in a for/if/switch init clause; and the enclosing function declaration must be known (a literal inside a package-level var initializer is left alone).

A literal that defers or recovers is no bar: its frame is an ordinary local of the local function, declared in the local function’s own body like any other, so the whole shape stays allocation-free —

nint /*r*/ guard(nint n) {
    nint r = default!;
    GoFrame  = default;
    try {
        defer(() => {
            {
                var e = recover(); if (e != default!) {
                    r = -1;
                }
            }
        }, ref );
        if (n < 0) {
            throw panic("negative");
        }
        r = n * 2; goto done;
    }
    catch (Exception ex) when (GoFrame.IsPanic(ex, out PanicException? p)) { GoFrame.Capture(p); }
    finally { .Run(); }
    done: return r;
}

Go’s two-step recursion idiom (var f func(int) int; f = func(int) int {…}) is an ASSIGN, not a DEFINE, so it is not this shape at all and keeps the lambda — correctly, since the recursive reference reads f as a value. (Guarded by the LocalFunctionEmission behavioral test: the parseUint shape with a named result and a mutated capture, the expression-bodied collapse, a struct-and-array capture mutated through the local function, two nested levels, and the deferring/recovering literal above — plus four negative controls, one per disqualifying reason: value use, reassignment, the recursion two-step, and argument position. The golden pins the emitted form; the stdout comparison against go run pins the capture semantics.)

A variable DECLARED INSIDE a closure is not captured BY it

The escape analysis heap-boxes a local when something outside its frame can reach its storage; a closure is one such route, because the emitted C# serves the shared variable through a ж<T> box. The closure arm of that analysis matched on any mention of the object lexically inside a function literal’s body — and for a variable declared there, that mention is its own declaration. So a literal’s own local was treated as if the literal closed over it:

//  Go:   testing.AllocsPerRun(100, func() { var t Time; t.UnmarshalText(in) })
Δtesting.AllocsPerRun(100, () => {
    ref var tΔ1 = ref heap(new Δtime.Time(), out var tΔ1);   // ← 128 B, and ᏑtΔ1 is never used
    tΔ1.UnmarshalText(inʗ1);
});

The box ᏑtΔ1 is never referenced anywhere in the emitted bodyUnmarshalText is a this ref Time extension, which binds the variable directly — while the identical two statements written outside a closure emitted a plain Δtime.Time tΔ1 = default!;. The arm now skips an object whose declaration position lies inside the literal, and the emission is the plain local. That was the other 128 of time’s 216.

The narrowing direction of an escape rule is the dangerous one — an under-box drops writes silently — so the proof is stated rather than assumed. Go scoping puts a literal’s own local out of reach of every other frame, so there is nothing for a shared box to make visible; and every route by which such a local can still genuinely escape is decided by an arm that walks the whole enclosing function body, literal bodies included: &x / &x.f / &x[i] (the address-of arm), a pointer argument (the call arm), a go/defer use (their own arms), a capture-mode method call, and a pointer-receiver method value — Go’s (&x).M written without the &. None of them is lost. The skip also keeps descending rather than stopping, so a literal nested inside the skipped one — which does close over the variable — still gets its own turn through the arm and still marks the escape.

(Guarded by the ClosureLocalNoHeapBox behavioral test. Five of its eight probes are the boxes that must SURVIVE, one per escape route, and each writes through the escaping alias and reads the value back so an over-narrowed rule prints a wrong number rather than merely emitting a different shape; the two positive probes are the pointer-receiver-method and copy-only shapes that now emit plain locals. Its N3 probe is the nesting case, and it is also the interaction test with the local-function rule above: the nested literal is emitted as a local function and captures the surviving box.)

Capture-mode methods called through a value field of the receiver

A pointer-receiver method that takes the address of one of its own fields (func (c *Counter) Add(d int32) int32 { return bump(&c.n, d) }) is capture-mode: it is emitted with the heap box as its receiver (this ж<Counter> Ꮡc) so &c.n can field-reference the real storage as Ꮡc.of(Counter.Ꮡn). When another struct embeds such a type as a value field and drives it through that field — func (f *Flag) Incr() int32 { return f.c.Add(1) } — the call needs a ж<Counter> aliasing the real f.c. The enclosing method is therefore itself promoted to capture-mode (direct-ж), and f.c.Add(1) is emitted as (&f.c).Add(1):

public static int32 Incr(this ж<Flag> f) {
    ref var f = ref f.Value;
    return f.of(Flag.c).Add(1);   // f.c.Add(1) — nested field-address box
}

The nested Ꮡf.of(Flag.Ꮡc).of(Counter.Ꮡn) chain resolves each level through ж<T>.Value (which honors a parent that is itself a field/array reference), so writes land on the real embedded field rather than a copy. A plain (non-capture) value method called through the same field — f.c.Get() — is left as a normal f.c.Get() value call.

This field-address routing applies only to value fields. When the field is itself a pointer — e.g. cpuProfile’s log *profBuf, accessed as cpuprof.log where cpuprof is a heap-boxed global — its C# value is already a ж<profBuf> box, so a direct-ж method binds to it directly: cpuprof.log.close(). Taking the field’s address (Ꮡcpuprof.of(cpuProfile.Ꮡlog)) would double-box to ж<ж<profBuf>> (CS1929). The heap-boxed-receiver routing recognizes that a field selector or indexed element whose own type is a Go pointer is already a box and skips the &-machinery for it. This discriminates a pointer field of a boxed global (already a box) from a deref’d pointer parameter (s in s.Prev(), a value alias whose box is Ꮡs): the latter is a bare identifier, not a selector/index, so it is correctly still routed through Ꮡs. The same exclusion applies when the pointer field is reached through a pointer local rather than a boxed global — s := sl.mspan; s.gcmarkBits.bytep(…) where s is a *mspan local — which otherwise routed through the pointer-local-field address path (s.of(mspan.ᏑgcmarkBits)); the field value (~s).gcmarkBits is already the ж<gcBits>. (Guarded by the PointerFieldOfBoxedGlobal behavioral test, covering both the boxed-global cpuprof.log.write/.close form and the pointer-local s.log.push form; runtime exercises both pervasively, e.g. mspan.sweep.)

The same applies when the value field belongs to a package global rather than a receiver — ctrl.total.Add(5) where var ctrl controller and total is an atomic field. The method’s box address goes through the field-address machinery, Ꮡctrl.of(controller.Ꮡtotal).Add(5), not a bare prefix on ctrl.total (which would bind to the box variable Ꮡctrl, whose value type has no total member → CS1061). This is the form runtime uses pervasively for gcController, sched, memstats, etc. The method call itself triggers heap-boxing the global: when a pointer-receiver method is called on a (possibly nested) value field of a package value global, the escape pass marks that global address-taken so its box exists — the call site needs the box even when the global is never explicitly &-addressed elsewhere. This is gated on the method being ж-only (a pointer receiver): a same-package method known to be capture-mode, or any pointer-receiver method whose package’s capture-mode set is not locally available — the latter covers cross-package atomic methods (func (x *Uint32) Store), which are likewise emitted with only a box receiver, so a plain value/ref of the field cannot bind them (CS1929). The walk to the global root bails at any pointer hop (a field reached through a pointer already has a real address and is handled by the pointer-local / receiver paths), so a receiver/parameter field such as f.c is never disturbed. (Guarded by the AtomicValues behavioral test’s global-atomic-field case; runtime exercises this for prof.signalLock, trace.seqlock, scavenge.gcPercentGoal, etc.)

It also applies when the receiver is an indexed element of such a field — trace.stackTab[i].dump() (boxed global) — where the element’s address goes through the box-field accessor: Ꮡ(trace.stackTab, i).dump() for a slice field, or Ꮡtrace.of(T.ᏑstackTab).at<E>(i).dump() for an array field. The same routing covers an indexed element of an array/slice reached through a pointerbh.Value[i].Load(), where bh is a pointer and the element is an atomic value — emitted bh.of(T.Ꮡval).at<E>(i).Load(). This is gated on the called method being direct-ж (a box receiver): an ordinary [GoRecv] ref method binds to an addressable element directly, so it is left as container[i].method() and only a direct-ж method (which truly needs the box) is routed — avoiding needless churn on the common case. (Guarded by the IndexedElementDirectBoxMethod behavioral test — a direct-ж method on an array-element-through-a-pointer-parameter, with mutation persistence verified; runtime hits this on mprof’s bh.Value[i].Load()/.StoreNoWB().)

A capture-mode method called on a value local of an inherently-heap type — a named slice/map/chan — also forces the box, which identHasHeapBox otherwise refuses. An inherently-heap type is already a reference, so a var of it is normally not boxed even when it “escapes” (the escape pass marks every inherently-heap local escaping and returns early). But a capture-mode pointer-receiver method — internal/trace/internal/oldtrace’s orderEventList (a named []orderEvent) with heap.Interface Push/Pop that forward the receiver to heapUp(h, …)/heapDown(h, …) — is emitted with a ж<orderEventList> receiver, so a plain value cannot bind it (CS1929 — var frontier orderEventList; frontier.Push(…)). The escape pass therefore records the capture-mode reason in that inherently-heap early-return branch (the only place these vars are seen, before the general address-of scan), and identHasHeapBox honors it — emitting ref var frontier = ref heap<orderEventList>(out var Ꮡfrontier) so the calls route Ꮡfrontier.Push(…)/Ꮡfrontier.Pop() through the box. A named slice/map/chan with no capture-mode method called on it stays unboxed (already a reference — no churn). (Guarded by the NamedSliceCaptureMethod behavioral test — a named-slice value local with *stack push/pop that forward the receiver to helpers, mutated and read through the same box, output-compared vs Go.)

A capture-mode method called on a value PARAMETER boxes the parameter at entry — go/format’s format(…, cfg printer.Config) calling cfg.Fprint(&buf, fset, file), where (*printer.Config).Fprint is transitively direct-ж (its body calls the defer/recover-wrapped fprint on its own receiver), so its only emitted receiver form is the box ж<Config> and the raw value parameter cannot bind it (CS1929 ×2). Parameters are deliberately never fed through the full escape analysis, so the escape pass runs only narrow, named parameter checks (markCaptureModeBoxedParams) rather than the general escape walk — this one being bodyCallsCaptureModeMethodOn, the same predicate the local-var arms use. (The companion check, objectAddressTaken, was added later — see An address-taken VALUE PARAMETER heap-boxes too above; before it, a plain &param did use the Ꮡ(value) copy-box.) For a marked param the signature renames the incoming value to the ʗp form (the variadic-prologue rename convention) and the parameter preamble declares the boxed alias:

internal static (slice<byte>, error) format(, printer.Config cfgʗp) {
    ref var cfg = ref heap(cfgʗp, out var cfg);
    
    cfg.Indent = indent + indentAdj;        // body writes hit the boxed storage…
    var err = cfg.Fprint();               // …the same storage the callee mutates through the receiver

Entry-time boxing is the load-bearing choice: Go auto-addresses the parameter (cfg.Fprint(…)(&cfg).Fprint(…)), so a body write before the call (cfg.Indent = …) must be seen by the callee, and the callee’s writes through the receiver pointer must be seen by the rest of the body — while the caller’s argument stays untouched (by-value parameter). A call-site Ꮡ(cfg) copy-box compiles but silently drops the callee’s writes for the rest of the function. An ARRAY param folds its Go by-value clone into the box init (ref var b = ref heap(bʗp.Clone(), out var Ꮡb); — the plain b = b.Clone(); preamble line is skipped), and an inherently-heap-typed param records the capture-mode box reason exactly like the value-local arm above. Beyond this trigger and the address-taken one, a param that leaks into identEscapesHeap some other way — a mixed data, pc, line := … define re-uses the param object, so the define walker escape-analyzes it (debug/gosym’s slice) — keeps its historical unboxed emission (paramNeedsHeapBox re-verifies the predicate against the declaring ident). Whole-stdlib reconvert diff: exactly go/format’s internal.cs changed, nothing else. (Guarded by the CaptureModeValueParam behavioral test — a defer-promoted direct-ж method plus a transitively-promoted one called on a value parameter, with a pre-call write observed by the callee, callee writes read back after, and the caller’s copy proven untouched, output-compared vs Go — and by the CaptureModeValueParamLib/CaptureModeValueParamUser cross-package pair mirroring the format→printer shape: a foreign Config value param, Fprint → defer/recover fprint transitive promotion, trace accumulation across two calls proving write-visibility through the foreign ж<Config> extension.)

When the same function also contains a func literal or defer that references the boxed parameter, the in-lambda references must route through the box — the capture analysis marks such a param box-ref (the same arm family as a deref’d pointer parameter, whose ref var p = ref Ꮡp.Value alias shares the exact shape). The boxed param’s Go name is a ref-local alias, which a C# lambda cannot capture (CS8175), and the general capture-snapshot fallback (var tʗ1 = t; before the lambda) compiles but divorces the closure from the boxed storage Go shares between the closure and the direct-ж callee: a closure read misses the callee’s writes through the receiver pointer, a closure write is invisible to the callee, and a deferred closure observes entry-time values instead of return-time state. With the box-ref mark, a closure read emits var get = () => Ꮡt.Value.total;, a closure write Ꮡt.Value.total += 100;, and a deferred observer defer(() => { (result, log) = (Ꮡt.Value.total, Ꮡt.Value.log); }, ref ᒐ); — the box Ꮡt is a plain ж<T> local, captured by reference, so every reference (body, closure, callee) hits the one boxed storage, matching Go’s one-parameter-variable semantics. A deferred direct-ж method value on the param itself (defer t.Add(n)) needed no change — it already routes through the box (defer(Ꮡt.Add, n, ref ᒐ)), binding the receiver address at defer time exactly like Go. Whole-stdlib reconvert diff: zero files — no stdlib function composes a capture-mode-boxed param with a closure today, so the composition is user-code-facing and was guard-discovered. (Guarded by the CaptureModeParamClosure behavioral test — four compositions with write-visibility checks in both directions: a closure read that must see the callee’s later write, a closure write the callee must observe (and vice versa), a deferred closure reading return-time state, and a deferred method value whose writes a sibling deferred observer reads; each output-compared vs Go, with the caller’s copy proven untouched. Under the pre-fix snapshot emission all four compiled and produced wrong values.)

Entry-time boxing extends to a function literal’s own value parameter — the original coverage walked only *ast.FuncDecl params, so f := func(t Tally, m int) {…; t.Add(m); …} rendered the raw Tally value against Add’s only ж<Tally> receiver form (CS1929). The escape pass marks literal params with the same one-narrow-predicate check as declaration params (walking FuncLit nodes before the define walk, so a mixed t, y := … re-use cannot pre-empt the verdict; a leaked-but-not-capture-mode param keeps its historical unboxed emission via the same declaring-ident re-verification). The literal’s signature takes the incoming value under the ʗp name and its first block statement is the boxed re-declaration — the exact preamble form, injected before the single-return collapse (which it thereby suppresses, correctly keeping the body a block):

var f = (Tally tʗp, nint m) => {
    ref var t = ref heap(tʗp, out var t);
    t.total++;                    // body writes hit the boxed storage…
    t.Add(m);                    // …the same storage the callee mutates
    return (t.total, t.log);
};

This applies uniformly to every literal form: an assigned literal, a call argument, a defer func(t Tally) {…}(x) / go … argument-passing target (each deferred/goroutine run boxes its own copy at entry), and — unlike the variadic prologue, which excludes them — an IIFE, whose names-only parameter list emits the ʗp name so the rebinding composes with the delegate cast. A literal with both a variadic tail and a boxed param stacks the two ʗp prologues (variadic slice first, matching the declaration preamble order). A nested closure over the literal’s boxed param takes the box-ref route (never a value snapshot, which compiled but orphaned the callee’s writes — var tʗ1 = t; tʗ1.Add(9) lost both directions of write-visibility), while the literal’s own body keeps the plain ref-alias renders above (t.total++, not Ꮡt.Value.total++): a box-ref var whose declaring literal is the lambda currently being converted renders plain, since its box and alias are locals of that very lambda — only genuinely nested lambdas read through the box. Whole-stdlib reconvert diff: zero files — no stdlib literal calls a capture-mode method on its own value param today, so this is user-code-facing and guard-discovered. (Guarded by the CaptureModeFuncLitParam behavioral test — assigned, IIFE, deferred-argument, nested-closure, and variadic-composition shapes, each with write-visibility checked in both directions and the caller’s copy proven untouched, output-compared vs Go.)

And it applies when the field belongs to a pointer localh.s.inc() where h is a *holder local and inc has a pointer receiver. A pointer local holds the box ж<holder> directly, so the value ~ dereference of the field ((~h).s) is an rvalue; the [GoRecv] method needs an addressable receiver (CS1510 on the generated ref). The field’s box address is taken instead — h.of(holder.Ꮡs).inc() — binding the ж overload. (A pointer parameter is deref-aliased to a value, so p.s.inc() already works without this and is left alone. This is the form runtime uses for (*c).gp.set(…) / .cas(…) in coro.)

Finally, the same rvalue problem occurs when the field belongs to a pointer reached through another fieldo.h.wait.add(…) where o.h is a *holder field and wait is a value (atomic) field. o.h dereferences to an rvalue, so (~o.h).wait is not addressable. The receiver is routed through the box-field accessor o.h.of(holder.Ꮡwait), which aliases the real field storage — not a Ꮡ(value) copy, which compiles but silently boxes a copy so the atomic write is lost (a behavioral bug, not a compile error). Both the explicit address form (&o.h.wait) and a pointer-receiver method call on the field are routed this way. This is deliberately scoped to a base that is itself a field selector: a bare-ident base is the method’s own receiver or a deref’d pointer parameter (both emitted as an addressable ref, so f.c.Get() binds directly — routing them through & would emit Ꮡf.of(…) but a value-ref receiver has no Ꮡf box) or a pointer local (handled above). (Guarded by the AtomicFieldThroughPointer behavioral test — a mutate-then-read proves the real field is updated, not a copy; runtime exercises this for atomic fields reached through pointer chains such as sgp.g.selectDone.CompareAndSwap and gp.m.mLockProfile.recordLock.)

The base may also be a pointer rvalue — a pointer-returning call (getg().schedlink.set(…), q.tail.ptr().schedlink.set(…), Δp.chunkOf(ci).scavenged.setRange(…), getg().m.p.ptr().wbBuf.get2()) or a pointer element index (batch[i].schedlink.set(…)). Go auto-derefs the pointer to reach the value field, so the converter renders the read as (~rvalue).field; the ~ deref is an rvalue, so a pointer-receiver method on it cannot bind (CS1510 on the generated ref). Unlike a deref-aliased parameter (whose box is Ꮡp) or a field deref (handled above), the call/index value already is the ж<T> box, so the receiver is materialized straight through it via the box-field accessor — getg().of(g.Ꮡschedlink).set(…), batch[i].of(g.Ꮡschedlink).set(…) — never a Ꮡ(value) copy (which would lose the write). The routing is scoped to a base that is not an ident and not a field selector (those are the param/receiver/local/field cases above) and is not a type conversion: a conversion (*T)(p) renders as a C# cast ((ж<T>)(uintptr)(…)), a low-precedence form on which a trailing .of(…) would mis-bind to the inner operand, so a pointer-reinterpret keeps its existing Ꮡ(…) form (the runtime-unsafe S1 territory). (Guarded by the PointerRvalueFieldReceiver behavioral test — a pointer-receiver method on a value field reached through a returning call, a method-call chain, and a pointer-element index, each with write-through verified; runtime exercises this for guintptr.set via getg()/batch[i]/q.tail.ptr(), pallocData.setRange via chunkOf, and wbBuf.get2/discard via getg().m.p.ptr().)

A TYPE-ASSERTION base is a pointer rvalue too (2026-07-31). The shape list that admits a base into that box-field routing enumerates ident / selector / call / index / star, and a type assertion is none of them — so &c.(*UDPConn).conn (net udpsock_test, reaching the promoted conn.Write through an asserted PacketConn) dropped to the Ꮡ(value) copy-box fallback and named a .conn member that ж<UDPConn> does not have (CS1061; had it bound, it would have written into a copy). The list is about C# precedence, not about which node kinds have happened to come up: a base whose rendering is postfix chains .of(…) cleanly, and a type assertion always renders as the postfix c._<ж<UDPConn>>(), which is the box. Only the type-CONVERSION CallExpr stays excluded, for the cast-precedence reason stated above. exprIsValueFieldOfPointerRvalue, the sibling predicate that decides the routing, already accepted a type assertion through its default arm — so the two now agree rather than one routing a shape the other could not render. This is the address-of copy-boxing family’s next uncovered base shape; the pattern of that family is that each fix covers one base shape, so the next uncovered one is worth looking for rather than waiting for. (Guarded by the PointerRvalueFieldReceiver extension — iface.(*node).s.set(55), with the write read back through the original pointer.)

It has a second consumer, in an already-banked package, found by the validated sweep rather than predicted: compress/flate’s flate_test.go reaches dict.availWrite() through an asserted *decompressor, and the old emission copy-boxed it —

((~r._<ж<decompressor>>()).dict).availWrite()                      // copy
r._<ж<decompressor>>().of(decompressor.dict).availWrite()         // the real field

— which flate survived only because availWrite is a read. That is this family’s signature exactly: the copy gives the right answer until someone writes through it, and the documented “faithful for reads” caveat is a latent wrong answer with a timer on it. compress/flate re-validates at its banked 64/64 with the corrected emission.

The bare-ident-base exclusion above holds only for [GoRecv] ref methods (which bind on the addressable value alias directly). A direct-ж (box-receiver) method — func (s *scavengeIndex) find(…) and the like, emitted with a ж<T> receiver — needs the box, so calling it on a value field-chain rooted at a deref-aliased pointer parameter or (direct-ж) receiver is CS1929: Δp.scav.index.find(force) (root p, a *pageAlloc receiver), mp.trace.seqlock.Load() (root mp, a *m parameter), h.userArena.readyList.remove(s). These are routed through the box-field accessor too — Ꮡp.of(pageAlloc.Ꮡscav).of(pageAlloc_scav.Ꮡindex).find(force) — never a Ꮡ(value) copy (which would lose an atomic write). The &-machinery recurses through the value field-chain to the param/receiver box: &Δp.scav.index builds Ꮡp.of(…).of(…), where the box base is the raw parameter name (Ꮡp, not the shadow-renamed ᏑΔp — a deref param pΔp is ref var Δp = ref Ꮡp.Value, box Ꮡp). The routing is gated to direct-ж so a [GoRecv] ref method on the same chain keeps binding directly (no churn); a receiver root additionally requires the enclosing method to be direct-ж (only then does its receiver box Ꮡrecv exist). (Guarded by the FieldChainBoxReceiver behavioral test — a direct-ж method on a value field-chain rooted at a pointer parameter and at a direct-ж receiver, both with write-through verified; runtime exercises this pervasively for scavengeIndex/mSpanList/timers methods and m.trace atomic fields.)

For the receiver-root case, the enclosing method only becomes direct-ж through the capture-mode pre-pass’s transitive fixpoint: a pointer-receiver method that calls a direct-ж method on a value field-chain of its own receiver — func (p *pageAlloc) free(…) { … p.scav.index.free(…) } — is promoted to direct-ж so its receiver box Ꮡp exists for the routing above. This detection walks the full value field-chain recvName.f1.…fn.method (every hop a value, non-pointer field), not just one level: p.scav.index.free(…) roots free at the receiver p through two value fields (scavindex). A one-level chain (b.u.Load() on an embedded atomic) was already detected; the multi-level walk generalizes it. A pointer field anywhere in the chain stops the walk — that subexpression is already a box and roots the call elsewhere (the pointer-field paths above), so it must not trigger promotion. The promotion is transitive: once pageAlloc.free is direct-ж, its caller func (h *mheap) freeSpanLocked(…) { … h.pages.free(…) } is in turn promoted (now calling a direct-ж method on h.pages), and so on up the call graph until a root holding the value through a real box/pointer. (The multi-level receiver-root promotion is covered by the FieldChainBoxReceiver test’s deep.bumpDeep case — d.mid.c.inc(), a direct-ж inc on a two-level value field-chain of a receiver with no other direct-ж trigger, write-through verified; runtime exercises it on pageAlloc.free/freeSpanLocked.)

unsafe.Alignof / unsafe.Offsetof name a TYPE, resolved through go/types

Go defines Sizeof, Alignof and Offsetof against the static type of their operand — and never evaluates that operand (all three are compile-time constants for any type of non-variable size). golib matches that shape: Alignof(Type, string? fieldName = null) and Offsetof(Type structType, string fieldName) take a System.Type, so the converter has to turn the Go operand into a type argument.

It now does that from go/types: unsafe.Alignof(x) emits @unsafe.Alignof(typeof(T)) for T the C# rendering of the operand’s static type — one rule for every operand shape, because Go’s Alignof(s.f) is the required alignment of the field’s own type, which is exactly what golib’s two-argument overload resolves to anyway. unsafe.Offsetof(s.f) emits @unsafe.Offsetof(typeof(S), "f"), where S comes from types.Selection.Recv() with any implicit pointer dereference stripped, and a promoted field is walked down its embedding chain so the offset is measured against the struct that declares it (Go’s rule: relative to the immediately enclosing struct). The field name is the emitted identifier with any keyword escape removed, since reflection sees @out as out.

The shape was previously derived by splitting the converted C# text on . and reading the pieces as if they were a Go field selector — one part meant x, two meant s.f, anything else warned and fell through to an emission that cannot compile. That mistakes any dotted rendering for a selector and corrupts every operand that is not literally an identifier or a one-level selection: a conversion operand renders with a leading cast, so unsafe.Alignof(uint32(0)) became (uint32)0.GetType(), which C# parses as (uint32)(0.GetType())CS0030: Cannot convert type 'System.Type' to 'uint', and the sole build blocker on crypto/md5 (its benchmarkSize alignment probe); a ж dereference Ꮡx.Value read as struct Ꮡx with field Value; and a two-level cpu.X86.HasAVX was rejected outright. .GetType() was also the wrong instrument on its own terms — it reports the dynamic type of a boxed or interface-typed operand where Go uses the static one, and it evaluates the operand, which Go does not. (unsafe.Sizeof was unaffected: it emitted the generic @unsafe.Sizeof(x), whose type argument C# infers.) Guarded by the UnsafeOperations behavioral test, extended with a conversion operand, an index operand, a selector through a pointer, a two-level selector, and a field whose name is a C# keyword; all output-compared vs go run.

This shape is now the FALLBACK, not the normal path — expression sites fold to the constant (next section), and only a variable-size operand still reaches the typeof(T) emission. The promoted-field rule stated above is also wrong about Go, and the fold supersedes it: Go measures a promoted field against the operand struct, not against the struct that declares it.

unsafe.Sizeof / Alignof / Offsetof FOLD to a constant at expression sites

Go computes all three at compile time from the operand’s static type, never evaluates the operand, and yields a typed uintptr constant for any operand type of non-variable size. Declaration sites have always emitted that constant — internal static uintptr offsetX86HasAVX => /* unsafe.Offsetof(cpu.X86.HasAVX) */ 66; in runtime/cpuflags.cs. Expression sites now emit the same form, so one Go construct has one behavior:

var hdr Header32
data := make([]byte, unsafe.Sizeof(hdr))
// …
f.Type = Type(bo.Uint16(data[unsafe.Offsetof(hdr.Type):]))
var data = new slice<byte>((nint)(/* unsafe.Sizeof(hdr) */ (uintptr)52));
// …
f.Value.Type = ((Type)bo.Uint16(data[(int)(/* unsafe.Offsetof(hdr.Type) */ (uintptr)16)..]));

The value comes from go/types, which folds against the types.Sizes for the loaded target GOARCH — the Go compiler’s own layout rules — so the emitted number is what the Go program computes, not a measurement of the emitted C#. The literal keeps its uintptr type because Go’s constant is typed: a bare number would let uadd := unsafe.Sizeof(*t) infer C# int, and an int variable has no implicit conversion back to nuint (internal/abi’s FuncType.InSlice hands it to a uintptr parameter — CS1503). The cast is inert everywhere else: C#’s constant-expression conversion would have bound a bare literal anyway, and a cast binds tighter than every binary operator, so no site needs extra parentheses.

Three things this fixes, beyond removing a reflection call from a construct Go settles at compile time:

A variable-size operand still emits the run-time form (with a converter warning naming the site), because Go itself does not fold it: since Go 1.18 the operand may be type-parameter-typed, and the call is then not a constant. Four such sites exist in the stdlib — slices.Compact (unsafe.Sizeof(a[0]) on S ~[]E), internal/saferio (unsafe.Sizeof(v) on E), and runtime/minmax (×2) — and they are why golib’s @unsafe run-time forms are retained rather than deleted.

Measured over the full stdlib (seeded A/B reconvert, Go 1.23.1, windows/amd64): 262 expression sites folded across 61 files in 16 packagesruntime 129, debug/elf 70, syscall 17, internal/poll 13, then a long tail; declaration sites byte-identical. Design record: docs/phase4/DESIGN-unsafe-constant-folding.md. Guarded by UnsafeOperations, extended with all three builtins in call-argument, arithmetic, comparison, assignment and compound-assignment, and make-size positions, over structs whose Go layout is padding-, embedding- and array-sensitive (and non-blittable once converted); output-compared vs go run.

The run-time unsafe.Sizeof answers through Go’s layout rule, not the CLR’s marshaller

The folding arc above removed the reflection call from every site Go itself settles at compile time, and named what was left: the handful of operands whose type is a type parameter, which Go’s own spec calls variable-size and does not fold either. Those kept riding Marshal.SizeOf<T> — and there the latent throw the folding arc had just designed around was not latent at all, because a type parameter binds at run time to exactly the shapes Marshal.SizeOf refuses: a generic type (ж<Section>, slice<T>) raises “The specified Type must not be a generic type”, and a struct holding a managed reference raises “cannot be marshaled as an unmanaged structure”.

Three packages died on it at once, all through the same one line — internal/saferio.SliceCap[E], which asks unsafe.Sizeof(*new(E)) only to decide how large a chunk it may pre-allocate: debug/macho (E = the Load interface), internal/xcoff (E = ж<Section>), and go/internal/gccgoimporter through debug/elf (E = ΔSection, a struct over an embedded header, an io.ReaderAt and a ж<SectionReader>).

So the run-time form now answers through GoReflect.GoSizeOf — the same Go-layout walk the reflection bridge already stamps into a descriptor’s Size_, and the same one reflect.Type.Size() reads:

public static uintptr Sizeof<T>(T x) {
    nint size = GoReflect.GoSizeOf(typeof(T), GoReflect.ArrayDimsOfValue(x));
    return size >= 0 ? (uintptr)size : (uintptr)Marshal.SizeOf<T>();
}

typeof(T) is Go’s rule verbatim — Sizeof is defined against the operand’s static type, and the converter’s inferred type argument is that type. Dims come from the live value because array<T> carries its Go length in the instance, not the type. Marshal.SizeOf stays as the fallback for the shapes GoSizeOf declines (-1: an array whose length nothing can reveal, a struct holding such a field), so no operand that resolved before stops resolving.

This makes the answer correct as well as non-throwing, which matters beyond the three packages: Marshal.SizeOf reports a bool as 4 bytes where Go says 1, so any struct containing one was already measured wrong — silently, at the sites the folding arc could not reach. A Go size now has one definition in the runtime rather than two (the unification golib/GoReflect.TypeLayout.cs had recorded as deferred pending a named consumer). Corpus reach is small by construction: 7 run-time call sites, against 283 folded ones.

Converting a Go pointer to unsafe.Pointer

unsafe.Pointer is the golib class unsafe_package.Pointer : ж<uintptr> (a numeric address wrapper). A uintptr/unsafe.Pointer argument converts through the implicit uintptr ↔ Pointer operators, but a Go pointer argument (*T, emitted as the managed box ж<T>) has no such conversion — a plain cast (@unsafe.Pointer)(ж<T>) is CS0030 (when T is unrelated to uintptr) or a runtime InvalidCastException (the base→derived downcast (@unsafe.Pointer)(ж<uintptr>) compiles but the object is a plain ж<uintptr>, not a Pointer). So unsafe.Pointer(ptr) for a pointer ptr is emitted through the golib helper that pins the pointed-to storage:

func (u *UnsafePointer) Load() unsafe.Pointer { return Loadp(unsafe.Pointer(&u.value)) }
public static @unsafe.Pointer Load(this ж<UnsafePointer> u) {
    ref var u = ref u.Value;
    return (uintptr)Loadp(@unsafe.Pointer.FromRef(ref (u.of(UnsafePointer.value)).Value));
}

The resulting numeric address is not GC-stable — the same caveat that applies to every unsafe.Pointer-as-uintptr use; the runtime intrinsics that consume it (e.g. Loadp, StorepNoWB) are assembly stubs, so this conversion is about producing compilable C#, not GC-correct pointer arithmetic. (The reinterpret pattern *(*U)(unsafe.Pointer(&x)) is handled separately and is not affected.)

A NIL pointer converts to address 0, not a throw. golib’s ж<T> → uintptr (and ж<T> → void*) operator takes the pointed-to storage’s address via a fixed block — but a nil box has no storage to pin, so &value.Value dereferences it and throws. Go’s uintptr(unsafe.Pointer(nil)) is simply 0, and the syscall wrappers pass nil pointers exactly this way: syscall.Write hands writeFile a nil *Overlapped for a synchronous write, then passes uintptr(unsafe.Pointer(overlapped)) (= 0) to the SyscallN trampoline. The operators now return 0/null for a nil box before pinning — so any converted os.Stdout.Write (hence fmt.Println) whose stdout is a pipe reaches the OS WriteFile and prints, instead of crashing on the nil-overlapped argument. (Guarded by the NilPointerUintptr behavioral output test — uintptr(unsafe.Pointer(nilPtr)) == 0 and a non-nil control, vs Go.)

A PACKAGE-SCOPE uintptr(unsafe.Pointer(...)) must not crash the converter. The unsafe.Pointer conversion path has a special case that rewrites unsafe.Pointer(arg) into the ref-based extension call (uintptr)@unsafe.Pointer.FromRef(ref arg) when the enclosing function is a pointer-receiver method whose single argument aliases the receiver (pointer-receiver methods are emitted as ref-based extension functions, so the pointer must be reconstructed from a ref). That test read v.currentFuncSignature.Recv() unconditionally — but a package-level var initializer is converted with no enclosing function, so currentFuncSignature is nil and the receiver probe nil-panicked during conversion (go/types.(*Signature).Recv). The special case can never apply at package scope — there is no receiver — so the fix guards it with v.currentFuncSignature != nil (the same idiom convUnaryExpr and captureModeOperations already use), which falls through to the ordinary emission: var gPtr uintptr = uintptr(unsafe.Pointer(&global))(uintptr)Ꮡglobal (it read (uintptr)new @unsafe.Pointer(Ꮡglobal) until the dead-wrapper peephole below), identical to the in-function non-receiver form the corpus already produces. This is what blocked cmp’s Phase-4 validation: cmp_test.go declares var nonnilptr uintptr = uintptr(unsafe.Pointer(&negzero)) and var nilptr uintptr = uintptr(unsafe.Pointer(nil)) at package scope, and the converter crashed before emitting a line.

A null Pointer reference (from unsafe.Pointer(nil)) also converts to 0. Distinct from the nil-box case above: the untyped nil literal in unsafe.Pointer(nil) renders as (@unsafe.Pointer)default!, and default of the reference type Pointer is a C# null, not a ж<T> box. golib’s Pointer → uintptr operator then dereferenced value.Value and threw NullReferenceException — even though Pointer’s own ==/!= operators already treat a null reference as nil (value?.IsNull ?? true). The uintptr and void* conversion operators now honor that same null-tolerance (value is null ? 0/null : value.Value), so uintptr(unsafe.Pointer(nil)) yields 0 whether the nil arrives as a nil box or a null Pointer reference. (Both the package-scope crash and this null-reference conversion are guarded by the extended NilPointerUintptr behavioral output test — package-level var gPtr = uintptr(unsafe.Pointer(&global)) (non-zero) and var gNil = uintptr(unsafe.Pointer(nil)) (0), vs Go; the pre-fix converter panics on the package-scope declaration and, once past that, the pre-fix golib NREs on gNil.)

A pointer to a Go fixed array resolves to the array’s DATA, pinned across the FFI call. The other half of that fixed-block operator is wrong for a ж<array<T>> (unsafe.Pointer(&arr) where arr is a Go [N]T): &value.Value is the address of the golib array<T> struct wrapper — which holds the backing T[] as a reference field, an offset, and a length — not the address of the array data, and the fixed releases it before the operator even returns. A native syscall handed that address writes over the wrapper’s fields (clobbering the T[] reference), so a later buf[i] reads through a corrupted array and faults. This is exactly the go-isatty MSYS/cygwin-pipe probe: IsCygwinTerminal fills a [262]uint16 with a FILE_NAME_INFO via GetFileInformationByHandleEx(…, uintptr(unsafe.Pointer(&buf)), …), then reads l := *(*uint32)(unsafe.Pointer(&buf)) (the FileNameLength) and slices buf[2 : 2+l/2] — the garbage l drove array<uint16>.get_Item(Range) off the end (AccessViolationException). The converted fatih/color sample went empty on a pipe because of it: fatih/color’s NoColor probe evaluates !isatty.IsTerminal(fd) && !isatty.IsCygwinTerminal(fd), so only a pipe (where IsTerminal is false, unlike a console, and GetFileType is FILE_TYPE_PIPE, unlike a file) reaches the faulting FFI call — file-redirect and real-console output were fine, matching the observed matrix. The operators now special-case a value that is a Go fixed array — an IArray that is not an ISlice (a slice<T>’s &s is its header, exactly as in Go, so slices stay on the value-slot path) — and return the pinned address of element 0 of the backing T[], via a PinnedBuffer (a GCHandle.Alloc(…, Pinned)) cached on the box. The pin lives for the box’s lifetime — so the syscall write lands in the real backing array and every managed read afterward (the l reinterpret and the buf[2:] slice) observes it — and is released when the box is collected (the PinnedBuffer finalizer frees the handle). This is a golib-only change (no emitted-code difference); the array-buffer-to-syscall pattern that previously faulted now runs, while non-array pointers keep the existing transient fixed-address behavior byte-for-byte. (Guarded by the FixedArrayBufferPointer behavioral output test — the *(*uint32)(unsafe.Pointer(&buf)) read-back idiom, the array still readable through its own indexer afterward, and address-stability across repeated conversions, vs Go; end-to-end, the converted fatih/color sample now prints byte-identically to go run through a pipe.)

EVERY managed address handed to native code is pinned for the pointer’s lifetime — a fixed block cannot outlive its own statement (2026-08-03, r38-os-fin). The entry above pinned the ONE case that had been proven to corrupt memory; the general case was left with the transient fixed address, under a soundness note in syscall/dll_windows.cs that called the window between capture and the trampoline’s calli “short and allocation-free”. It is neither, for a BLOCKING syscall: the window stays open for as long as the kernel takes. os’s TestPipeEOF parks in ReadFile on a pipe for 10 ms per read while the rest of a parallel suite allocates around it, and a gen0 collection in that window moves both the *uint32 byte-count box syscall.Read passes and the caller’s read buffer — measured directly, a heap(new uint32(), out var Ꮡdone) box and a Ꮡ(buf, 0) element pointer BOTH report a different address after one forced collection. The kernel then writes to neither: done stays 0, syscall.Read returns (0, nil), and internal/poll’s FD.eofError turns that into a premature io.EOF. That is the whole of the row characterized as bufio.Reader.ReadBytes over a converted os.Pipe returns a premature io.EOF, and only under parallel load”, and it explains its measured shape exactly — monotone in the parallelism level (0 of 4 at -parallel 1, 1 of 3 at 4, 5 of 5 at 8, 100% at the default), because more threads means more allocation means more collections inside the same 10 ms window; independent of finalizers, which a control had already ruled out. The buffer’s half of the same defect writes 4 KB into freed heap, which is the moving-site ExecutionEngineException recorded beside it. golib now pins before it reads an address: ж<T>’s uintptr/void* operators call EnsureStableAddress, which takes a lifetime GCHandle on the ROOT storage the pointer names — a standard heap box pins its own value slot, an element reference pins the canonical backing array, a field reference recurses to the allocation that contains the field — on exactly the terms pinnedArrayData already used for the fixed-array case, and released when the box is collected. The enabling change is that a standard heap box’s value STORAGE is now a one-element array for a T that contains no references (ж<T>.m_slot): a box is a class with reference fields and GCHandle refuses to pin anything that contains pointers, so the value had nowhere pinnable to live. It is allocated EAGERLY and never migrated — heap<T>(out ж<T>) hands the caller a ref alias before any address is taken (ref var done = ref heap(new uint32(), out var Ꮡdone)), so moving the storage on first address-take would leave that alias on the abandoned copy, which is this very bug one level down. A T that DOES carry references gets no slot and keeps the transient address: its C# layout is not a native layout either, so no syscall can meaningfully be handed its address — the change is additive, and RuntimeHelpers.IsReferenceOrContainsReferences<T>() is a JIT constant, so neither the branch nor the allocation costs such a box anything. This also makes Go’s unsafe.Pointer RULE 3 (pointer arithmetic through uintptr) sound, which it silently was not. golib-only — no emitted-code difference. (Guarded by src/tests/GolibTests/NativeAddressStabilityTests.cs, a neutered-fix control across all four box kinds plus the reference-bearing negative case: with EnsureStableAddress removed every address assertion fails on the first forced collection. Operationally, os’s residual went from 13 rows to 3 in one change — TestPipeEOF and the whole child-stdout family, whose empty child output was the same premature EOF read through exec’s pipe.)

A STRUCT handed to the kernel by address must be blittable — otherwise the wrapper is hand-owned. The previous entry fixes a pointer to a fixed ARRAY; this is the same problem one level up, for a whole struct, and it has no golib-level answer. A generated syscall wrapper passes uintptr(unsafe.Pointer(&s)) and the kernel writes the NATIVE record at that address. That is safe only when the converted struct’s C# layout matches the native one — which it does for a scalar/handle struct (SecurityAttributes is two uint32s and a uintptr, so CreatePipe, and therefore os.Pipe, works through the ordinary converted wrapper), and never when a field is a golib array<T> (Go’s inline [N]T) or a ж<T> (Go’s pointer field): both are MANAGED REFERENCES occupying one word where Windows expects inline bytes or a raw address. The kernel then writes the native-sized record over a smaller managed object — corrupting the heap past its end and leaving fabricated object references in the reference-typed fields. It does not fail at the call. It fails at the next read of one of those fields, arbitrarily far away and with a diagnostic that names the wrong code: syscall.GetTimeZoneInformation writes 172 bytes of TIME_ZONE_INFORMATION (two inline WCHAR[32] name buffers) over a ~64-byte Timezoneinformation, and the crash surfaces as an ACCESS_VIOLATION inside slice<ushort>..ctor on zoneinfo_windows.go’s next syscall.UTF16ToString(z.StandardName[:]) — so every converted program calling time.Now().Weekday() / Location() / Local on Windows died, in time, with no mention of syscall. The remedy is per-wrapper hand-ownership, not a converter or golib change: a manualConversionFuncs entry turns the generated wrapper into a placeholder, and a *_impl.cs companion supplies a blittable [StructLayout(LayoutKind.Sequential)] mirror (fixed buffers for the inline arrays, so they stay inline), a direct [DllImport], and an explicit field-for-field copy back into the converted struct at the boundary — see src/core/syscall/zsyscall_windows_impl.cs, and exec_windows.cs’s StartProcess/_STARTUPINFOEXW for the first instance. Verify at VALUE level, never at fault level: a mirror with the wrong offsets returns garbage without crashing, so “it no longer faults” proves nothing. (Guarded by the LocalTimeZone behavioral output test, which compares the zone ABBREVIATION — which comes from the name buffers — the offset in seconds — from Bias/StandardBias/DaylightBias — and a fixed instant rendered through the local zone — which selects between them via StandardDate/DaylightDate — against go run.) A census of src/core/syscall finds 32 non-blittable converted structs and eleven wrappers passing one by address (an earlier count of ten collapsed the findFirstFile1/findNextFile1 pair into a single row); the ones not yet fixed are latent and board-rowed rather than fixed speculatively, since each needs its own value-level verification. src/core/syscall is not the class’s boundary, and the class runs in BOTH directions. internal/syscall/windows holds six more wrappers of the same shape, and where the kernel merely READS the record go2cs hands it the diagnosis above inverts: nothing is written over the managed object, so there is no delayed corruption — instead the native reader picks each field out of the C# storage at the NATIVE offset, and because the CLR auto-layouts a struct containing references (grouping them first) an ordinary integer field ends up under a pointer field. internal/syscall/windows.SHARE_INFO_2 is 48 managed bytes against 56 native ones, and netapi32 reads shi2_path at offset 40 — which in C# is MaxUses (1) followed by CurrentUses (0) — so it dereferences the pointer value 1 and the process dies AT the call with 0xC0000005; the same reordering makes shi2_passwd an 8-byte over-read past the end of the record. This shape is loud and immediate rather than distant, which makes it easy to misread as a regression in whatever changed most recently. Note also what is NOT the defect: a managed reference standing in for an LPWSTR is survivable on its own — it is a readable address, and a control that keeps the native field ORDER returns ERROR_INVALID_NAME instead of faulting — so the reordering, not the reference, is what a remedy must answer. os’s own readdir is the worked precedent for the read direction (src/core/os/windows/dir_windows_impl.cs walks the kernel’s buffer at native offsets rather than reinterpreting it as the managed surrogate). The write direction has no answer yet where the wrapper receives an opaque *byte: Reinterpret correctly declines to alias a reference-bearing struct as byte, and the address route it falls back to has already discarded the managed identity that a field-for-field copy would need.

Second and third members of the class: findFirstFile1 / findNextFile1 (2026-08-01). The same seam over a bigger record — WIN32_FIND_DATAW is 592 bytes with cFileName[260] and cAlternateFileName[14] INLINE (520 and 28 bytes of storage), where the converted win32finddata1 carries two one-word array<uint16> references — and the first member a real test suite actually reached: path/filepath.EvalSymlinkstoNormnormBase asks FindFirstFile for the on-disk spelling of every path element, so the whole EvalSymlinks family took the C# test host down mid-run, which silently under-reports every verdict after it as an empty result. Both faces of the corruption appeared from that one package — an IndexOutOfRangeException inside PinnedBuffer where the clobbered reference still resolved to something (normBase’s UTF16ToString(data.FileName[:])), and an outright ACCESS_VIOLATION in slice<ushort>..ctor where it did not (copyFindData’s src.FileName[..]). Only the two *1 wrappers are hand-owned: Go itself puts the native-layout boundary exactly there — syscall_windows.go’s FindFirstFile allocates a win32finddata1, calls the wrapper, then copyFindDatas out — so the public FindFirstFile/FindNextFile and copyFindData above them are pure Go logic and convert faithfully. Two details generalize to the next member of the class: the name argument is pinned with a fixed block wrapped around the call rather than handed golib’s TRANSIENT жuintptr address (Value on an element box resolves to a ref INTO the caller’s backing array, so fixed pins that array and the whole NUL-terminated name stays contiguous behind the pointer for the call); and both inline buffers are copied WHOLE, NULs included, because Go reads them as UTF16ToString(data.FileName[:]) — which stops at the first NUL — while the SAME win32finddata1 is reused across every FindNextFile of an enumeration, so a copy that stopped at the terminator would leave the previous entry’s runes behind it. Reserved0 (offset 36) is copied verbatim but deliberately not asserted: Windows documents it as the reparse-point tag only when FileAttributes carries FILE_ATTRIBUTE_REPARSE_POINT and UNDEFINED otherwise, so a reparse-free tree has no stable value to compare — its offset is instead pinned from both sides by the verified FileSizeLow (32) and FileName (44), which leave it and Reserved1 the only eight bytes between them. (Guarded by the FindFirstFileData behavioral output test, which builds a purpose-made tree and compares, per entry, the long name in both ASCII and non-ASCII — FileName at 44 — the 8.3 short name — AlternateFileName at 564 — the directory bit — FileAttributes at 0 — the byte size — FileSizeLow at 32 — and a DISTINCT fixed instant stamped per entry — LastWriteTime at 20 — plus ERROR_FILE_NOT_FOUND for a missing file, the ERROR_NO_MORE_FILES end of an enumeration, and filepath.EvalSymlinks restoring canonical case through the whole path/filepath path, all against go run.)

Fourth member, and the first carrying TWO defects: the sockaddr family (2026-08-11, lane L10). net.Listen on Windows is what forced this one, and it never even reached the seam — it died one layer earlier. Go writes the port in network byte order through a two-byte alias over the raw struct’s port field, p := (*[2]byte)(unsafe.Pointer(&sa.raw.Port)), and the auto conversion of that rebuilds an array<byte> from a raw address, which materializes default(array<byte>) — a LENGTH-ZERO array — so p[0] panicked with index out of range [0] with length 0 (golib array.cs:280 via syscall_windows.cs:881). An array<T> is a managed container with its own header, not two inline bytes, so no address reinterpret can ever produce one; the encoders are hand-owned and write the field arithmetically instead, leaving raw in exactly the state Go leaves it. Behind that sat the ordinary form of this class: RawSockaddrInet4’s Addr [4]byte / Zero [8]uint8 are array<byte> managed references, so unsafe.Pointer(&sa.raw) names a ~24-byte object with object references where Windows wants a 16-byte sockaddr_in with the octets inline — the case golib’s own ж.cs describes when it explains why a reference-bearing pointee gets no pinnable storage (“such a value’s C# layout is not a native layout either, so no syscall can meaningfully be handed its address”). Two departures from the precedent above are worth cribbing. The mirror is a LOCAL at each call site rather than a field the way Timezoneinformation’s is: a sockaddr’s native image is needed for the duration of one call, and a stack buffer is trivially stable for exactly that long, where a managed field’s address would need a pin whose lifetime nothing owns. And no new [DllImport]/[LibraryImport] is declared at all — golib models unsafe.Pointer as a box over a plain address (unsafe’s Pointer : ж<uintptr>, whose uintptr operator returns the stored address), so the package’s OWN generated bind/connect/connectEx wrappers already accept any address and were never the broken part; handing them the mirror’s address reuses their errno handling verbatim and keeps the hand-owned surface to the layout translation, which is the only thing that was wrong. Getsockname/Getpeername are the exception, and for a precise reason: their generated wrappers take a typed ж<RawSockaddrAny> rather than an address, so those two call the package’s Syscall trampoline directly, mirroring the generated wrappers’ error handling. (Guarded by the SockaddrRoundTrip behavioral output test — socket/bind/getsockname/listen/connect/getpeername on loopback over both IPv4 and IPv6 — which prints kernel-derived values and cross-checks them rather than checking for absence of a fault: the client’s getpeername must equal the listener’s bound address field for field, closing encode → kernel → decode → encode → kernel → decode. Ephemeral ports are never printed, only whether the two ends agree about them, so the output is host-independent.)

The DECODE joined the family three days later, and only because a CONVERTER capability landed first (2026-08-14, netpoll lane S2b). (*RawSockaddrAny).Sockaddr carries the same port alias as the encoders and panics identically, but L10 left it auto-converted on measurement rather than on effort — its body held the only ΔSockaddr casts in the package, so displacing it dropped the three GoImplement<…>(Pointer = true) records and net minted duplicates (the trap written up in the next paragraph). Once recordSamePackageImplements began recording the POINTER method set the records stopped depending on that body, the hand-own became available, and net’s ACCEPT path is what wanted it: it decodes the GetAcceptExSockaddrs output through this one method, the single route to a Sockaddr that the hand-owned Getsockname/Getpeername do not cover. Its shape differs from every other member of this class, and that is the transferable part. The others translate a MANAGED struct into a native image to hand the kernel; this one runs in the opposite direction with no kernel call in it at all — it FLATTENS the managed RawSockaddrAny back into the 116-byte native image its fields are a transcription of (Family at 0, Addr.Data covering 2..15, Pad covering 16..115) and hands that to readNativeSockaddr, the same decode Getsockname/Getpeername already route through. Two things force the flatten rather than a field-by-field read: the auto body’s Reinterpret<RawSockaddrAny, RawSockaddrInet4> cannot alias one reference-bearing struct as another (their managed layouts share no offsets at all), and a sockaddr_in6’s 16-byte address spans offsets 8..23 — Addr.Data[6..13] and Pad[0..7] — so a decode written against the managed fields has to know that boundary anyway. Reusing the one native decode keeps that knowledge in a single place. (Guarded by four new lines in SockaddrRoundTrip driving the method directly on hand-built RawSockaddrAny values: IPv4, an IPv6 case whose address deliberately crosses the Data/Pad boundary, an AF_UNIX name, and an unknown family that must answer EAFNOSUPPORT. The Go side writes those fields BY NATIVE OFFSET, so the test states the layout it depends on instead of assuming it.)

⚠ A hand-own removes its body’s EMISSION, and with it any [assembly: GoImplement] its body witnessed — check before listing a function in manualConversionFuncs. This is a general trap the sockaddr work surfaced, not a sockaddr detail. Every GoImplement record the converter writes comes from a CAST it converted, so a function whose body holds the only casts of a type to an interface is also the only thing recording that pair. RawSockaddrAny.Sockaddr is exactly that function for all three Sockaddr types, and hand-owning it dropped the three GoImplement<…, ΔSockaddr>(Pointer = true) records from syscall’s package_info.cs. The consequence is not a build failure, which is what makes it dangerous: a MEASURED reconvert of net against the shortened package_info showed net quietly minting its own syscall_SockaddrInet4жΔSockaddr adapters instead of using syscall’s — the SECOND-IDENTITY regression samePackageImplements.go exists to prevent, where reflect and fmt see the wrapper in place of the value’s own type and a direct-boxed value compares unequal to an adapter-wrapped one. Declaring the records in the *_impl.cs does NOT fix it: a dependent package’s converter run reads package_info.cs, not the manual file. Recording them from the method set is the real answer, and it has since LANDED — recordSamePackageImplements now covers the POINTER method set as well as the value one, so these three pairs are recorded from types.Implements(*T, Sockaddr) and no longer depend on any body being converted. Proven by re-running exactly the probe that measured the regression: with RawSockaddrAny.Sockaddr suppressed through manualConversionFuncs, syscall’s package_info.cs still carries all three (Pointer = true) records and a reconvert of net still references syscall.SockaddrInet4жΔSockaddr rather than minting its own. The general trap is narrower now but not gone, and the check is unchanged for the cases the recorder’s gates exclude — a pair whose interface or target is UNEXPORTED, a named FUNC target, a generic, or a promotion deeper than one embed hop is still witnessed only by its casts, as is any pair whose interface is declared in a DIFFERENT package (the recorder is same-package only, so a body holding the only *T → io.Reader cast is exactly as load-bearing as before). The cheap check before hand-owning anything: grep the body for interface conversions, and if it has any, reconvert one DEPENDENT package and diff.

The wall BEHIND this one (recorded 2026-08-11 so it is not rediscovered). Fixing the sockaddr seam does not by itself make net work, and the board’s expectation that it would was formed while the panic masked what follows. With bind succeeding, net.Listen walks on to internal/poll’s pollDesc.init and stops at runtime_pollServerInit — one of ten bodyless //go:linkname netpoll entry points that the PartialStubGenerator emits as “external (assembly or cgo) function is not implemented”. The counterpart exists in the converted runtime (runtime/netpoll.cs carries poll_runtime_pollServerInit), but nothing wires the linkname across assemblies — and wiring it would not be enough either, because the Windows implementation bottoms out in netpollinitstdcall2(_CreateIoCompletionPort)asmstdcall, itself a stub. So this is a second, independent seam whose honest remedy is the managed-API-boundary pattern already used for sync’s Mutex and runtime’s traceback surface — hand-own the ten runtime_poll* contracts against .NET’s own completion-port machinery rather than emulating Go’s poller — and it is a design arc, not a wrapper repair. RESOLVED for the listener lifecycle 2026-08-13 (design ruled, arc S1): the ten contracts are hand-owned in internal/poll/windows/runtime_netpoll_impl.cs and net.Listen now completes against a real kernel — see The managed netpoller below. The prediction in this paragraph held exactly, including that the deep wall is the scheduler rather than asmstdcall; what it did NOT anticipate is that making pollWait wake up is only half the arc, because the overlapped submissions execIO issues cannot yet reach the kernel safely (the OVERLAPPED lifetime seam, S2).

The same fork one step further out: REINTERPRETING an OS byte buffer as a Go struct (os.readReparseLink, 2026-08-02). The entries above are about a struct handed TO the kernel; this is the mirror — a byte slice the kernel FILLED, reinterpreted as a Go struct whose trailing inline array stands in for variable-length data written after it. internal/syscall/windows’s SymbolicLinkReparseBuffer / MountPointReparseBuffer both end in PathBuffer [1]uint16, and Path() reads the real name through (*[0xffff]uint16)(unsafe.Pointer(&rb.PathBuffer[0]))[n1:n2]. In the conversion that field is a golib array<uint16> — an 8-byte MANAGED REFERENCE where the OS wrote 2+ bytes of inline UTF-16 — so golib’s PointerExtensions.Reinterpret correctly declines to alias managed storage for a reference-bearing struct and falls back to the raw-address route; &rb.PathBuffer[0] then resolves an object reference synthesized out of path bytes and faults with an ACCESS_VIOLATION inside array<uint16>.get_Item. That KILLED the C# test host mid-run at os’s TestReadlink, at test 50 of 178, which is the same silent under-reporting shape findFirstFile1 produced for path/filepath. No converter or golib change can rescue it — a managed array reference can never be laid out like an inline OS array — so it takes the dir_windows_impl.cs remedy rather than the blittable-mirror one: manualConversionFuncs turns os.readReparseLink into a placeholder and src/core/os/file_windows_impl.cs decodes the record straight out of the byte slice at its documented offsets (REPARSE_DATA_BUFFER header 8 bytes; then SubstituteNameOffset/Length at +0/+2, PathBuffer at +12 for the symlink shape and +8 for the mount point), with every read an ordinary bounds-checked slice index — no pointer, no pinning, no unsafe block. openSymlink and normaliseLinkPath stay auto: they pass scalars, handles and strings. ⚠ syscall.Readlink carries the identical defect over its own private reparseDataBuffer / symbolicLinkReparseBuffer / mountPointReparseBuffer copies; it is LATENT (nothing in the validated corpus reaches it) and is recorded rather than fixed speculatively, per the do-it-when-a-suite-reaches-it rule above.

Atomic pointer ops on a MANAGED pointer field read/write the reference, not a uintptr. The lock-free-cache idiom atomic.LoadPointer((*unsafe.Pointer)(unsafe.Pointer(&x.field))) / atomic.StorePointer(…, unsafe.Pointer(v)) — where x.field has type *T and so holds a ж<T> reference — cannot go through the literal conversion: new @unsafe.Pointer(v) round-trips the managed reference through its (transient) address, and (ж<@unsafe.Pointer>)(uintptr)(FromRef(ref …field)) dereferences raw memory, losing GC identity (it NRE’d on the very first read — x/sys/windows’s LazyDLL/LazyProc proc caches at package-init). convCallExpr.managedAtomicPointerIdiom recognizes the idiom (the callee is sync/atomic.LoadPointer/StorePointer and the argument is (*unsafe.Pointer)(unsafe.Pointer(&Z)) with Z of pointer type) and emits golib’s managed-referent overloads on the field box directly: atomic.LoadPointer(Ꮡx.of(T.Ꮡfield))ж<ж<T>>Volatile.Read returning ж<T>, and atomic.StorePointer(Ꮡx.of(T.Ꮡfield), v)Volatile.Write of the plain ж<T> (the stored value unwrapped from its unsafe.Pointer(…) conversion). The overloads are additive — a ж<ж<T>> argument never matches the existing ж<@unsafe.Pointer> (= ж<Pointer>) signature, so ordinary unsafe.Pointer atomics are untouched. The load stays unsafe.Pointer-typed to Go, so a caller’s == nil still renders (uintptr)… == nil; the ж<T> → uintptr operator (above) yields 0 for a nil box, so the nil test is correct with no change to the surrounding emission. Blast radius is only the packages using the idiom (x/sys/windows and a handful of stdlib sites), each a pure re-shaping to the managed overload; CNR byte-identical across the behavioral corpus. (Guarded by the ManagedAtomicPointer behavioral output test — a *proc-field lock-free cache initialized once and re-read, vs Go; it NRE’d before the fix.)

The ref the helper takes depends on how the pointer argument renders. A genuine box — an address-of expression, a local pointer variable, a pointer field, a call result — is the ж<T> object, so the ref goes through its boxed value: FromRef(ref (box).Value). But a deref-aliased pointer — a pointer parameter or pointer receiver, which the body renders as the pointed-to value alias (ref var p = ref Ꮡp.Value) — is not a box; .Value on it is CS1061 (nuint has no Value — runtime select.go unsafe.Pointer(pc0) and heapdump.go unsafe.Pointer(pstk), both *uintptr parameters). The alias is itself a ref-local into the boxed storage, so the converter takes its ref directly: FromRef(ref p). Detection reuses exprIsDerefAliasedPointer (the same discriminator the pointer-reinterpret block uses). This also let the guintptr/muintptr receiver family (runtime2.go (*uintptr)(unsafe.Pointer(gp)) inside guintptr.cas) compile — previously ref (gp).Value bound the [GoType] wrapper’s Value property (CS0206); the CAS it feeds (atomic.Casuintptr) is a partial asm stub, so the copy-box semantics match the established reinterpret precedent (compile-milestone bar; the faithful managed-referent ж<T> model for those types remains a separate effort). (The bare unsafe.Pointer(p) pin stays exercised across the stdlib — runtime select.go/heapdump.go, and runtime2.go’s genuine *guintptr*uintptr reinterpret here, whose differing element types keep it off the identity path. The UnsafePointerParamPin behavioral output test now guards the same-type identity collapse of the (*uintptr)(unsafe.Pointer(p)) shape it originally used — see A SAME-TYPE reinterpret … collapses to the pointer itself above — where the whole conversion elides to the box; a (*byte)(unsafe.Pointer(&value))-style DIFFERENT-type reinterpret still pins through FromRef.)

Returning an unsafe.Pointer parameter whole is a plain value return. The return path boxes a pointer parameter returned whole (return preturn Ꮡp — the value alias cannot bind the pointer result), and the pointer-result check counts the UnsafePointer basic as a pointer. But an unsafe.Pointer parameter renders as a plain value param (@unsafe.Pointer zero) with no box, so the prefix referenced a nonexistent Ꮡzero/Ꮡv/Ꮡfd (CS0103 — runtime map.go mapaccess1_fat/mapaccess2_fat’s return zero, mem_windows.go, and panic.go readvarintUnsafe’s tuple return). The box form now applies only when the returned parameter’s own type is a genuine *T (deref-aliased, so Ꮡp exists); an unsafe.Pointer param returns as-is. (Guarded by the UnsafePointerParamPin extension — the whole-return, tuple-return, and genuine-*T-control shapes, values vs Go; cleared 4 runtime CS0103, 63 → 59.)

The reverse direction — reinterpreting a raw address as a pointer, (*T)(p) where p is an unsafe.Pointer (or uintptr) — is the reinterpret pattern referenced above. Its result is the pointer type ж<T>. A plain (ж<T>)p cast is CS0030: because unsafe.Pointer is Pointer : ж<uintptr>, reaching ж<T> needs the two chained user-defined conversions Pointer → uintptr → ж<T>, and C# performs at most one user-defined conversion in a cast. The converter routes explicitly through uintptr(ж<T>)(uintptr)(p) — which reads the T at p’s address via golib’s explicit operator ж<T>(uintptr value) => new ж<T>(*(T*)value) (with uintptr(Pointer) => Value, the address the pointer holds). The deref *((*unsafe.Pointer)(k)) then adds .Value: ((ж<@unsafe.Pointer>)(uintptr)(k)).Value — Go’s read of the unsafe.Pointer stored at k. This is the identical routing the dereference path ((*int)(p) inside *(...)) already used via its isPointerCast flag; the fix extends it to the two shapes that did not set that flag: a bare call argument atomicwb((*unsafe.Pointer)(ptr), new) (runtime atomic_pointer.go) and an extra-paren deref *((*unsafe.Pointer)(k)) (runtime map.go’s indirect key — convStarExpr’s dereference branch sees a ParenExpr, not the CallExpr, so it never marks the cast). Gated to a pointer-result conversion whose argument is a raw address (unsafe.Pointer/uintptr basic); the pointer-to-named-type value conversion (*Base)(defPtr) (below) has a *T argument, is handled earlier, and is not affected. Like every reinterpret through the uintptr round-trip, the golib operator reads/boxes a copy from a fixed address, so this is memory-layout-dependent code whose runtime values are not the contract — golib’s own map<K,V> is what actually runs; the converted runtime/map.go only needs to compile. (Guarded by the UnsafePointerReinterpret behavioral Compile + Target test — both the extra-paren deref and the bare-argument shapes; cleared all 21 unsafe.Pointer → ж<unsafe.Pointer> CS0030 in runtime, 137 → 114.)

A SAME-TYPE reinterpret (*T)(unsafe.Pointer(p)) where p is already *T collapses to the pointer itself. Converting a *T to unsafe.Pointer and back to the same *T is a no-op identity in Go — the language spec makes (*Builder)(abi.NoEscape(unsafe.Pointer(b))) exactly b.addr = b (strings.Builder’s copy-by-value guard; the type’s own TODO says to revert it to that once escape analysis improves). The uintptr round-trip above is wrong for this shape: golib’s ж<T>(uintptr) DEREFERENCES-and-COPIES, so b.addr became a fresh box over a copy of the receiver, never reference-equal to it — and the guard’s own b.addr != b self-check FALSE-PANICKED on the second call to any strings.Builder method (a Grow then a WriteString, or strings.Join’s repeated WriteString), surfacing as panic: strings: illegal use of non-zero Builder copied by value in the converted fatih/color -recurse sample the moment color was enabled. convCallExpr.pointerReinterpretIdentitySource intercepts this exact shape at the top of the conversion path — a (*T)(…) whose source, after peeling an optional escape-analysis identity wrapper (abi.NoEscape or a package-local noescape, matched by name and unsafe.Pointer→unsafe.Pointer signature), is unsafe.Pointer(p) with p of the identical pointer type *T — and emits p’s box directly (in the isPointer context, so a deref-aliased receiver/param renders Ꮡb, not its value alias b):

internal static void copyCheck(this ж<Builder> b) {
    ref var b = ref b.Value;
    if (b.addr == nil) {
        b.addr = b;                       // was (ж<Builder>)(uintptr)(abi.NoEscape((uintptr)@unsafe.Pointer.FromRef(ref b)))
    } else if (b.addr != b) {
        throw panic("strings: illegal use of non-zero Builder copied by value");
    }
}

This preserves pointer identity AND shared storage (a write through the reinterpreted pointer now flows back, unlike the copy). A different element type is a genuine reinterpret and keeps the uintptr round-trip; the interception is (*T)-target- and same-element-type-gated (types.Identical(srcElem, targetElem)), so it fires ONLY for the identity. Across the 302-package stdlib it rewrites exactly 8 latently-miscompiled sites (strings.Builder.copyCheck, internal/reflectlite, internal/syscall/windows/registry, os, syscall, and three runtime sites) to the cleaner, correct box form — CNR byte-identical everywhere else; the bare unsafe.Pointer(p) pin (61 files) and the genuine-reinterpret round-trip (130 files) both remain and stay compile-guarded by the full build. (Guarded by the PointerReinterpretIdentity behavioral output test — a Builder-style copyCheck self-reference called repeatedly must NOT panic, and a genuine copy-by-value MUST still be caught, vs Go; it panicked before the fix — plus the identity-collapse arms of UnsafePointerParamPin (param/receiver/field), PointerSelectorDeref, and PointerCastSliceRange.)

The identity also collapses when the source pointer is reached DIRECTLY — *(*T)(p) / (*T)(p) with p already *T. This is the same no-op, minus the unsafe.Pointer hop: Go’s way of re-reading a pointer at a fixed type. It was not recognised, and the deref path made it worse than the round-trip above. convStarExpr’s casted-pointer-deref branch sets isPointerCast, and the conversion renderer took that flag alone as licence to emit the raw-address bridge (ж<T>)(uintptr)(p). But isPointerCast means only “this conversion is the operand of a deref” — it says nothing about the source being an address, and the bridge is only ever correct for one that is. A typed Go pointer is a managed box, and a deref-aliased pointer parameter renders as that box’s value alias, so the (uintptr) leg had no conversion at all:

// Go:  func derefStruct(p *Pt) Pt { return *(*Pt)(p) }
internal static Pt derefStruct(ж<Pt> p) {
    ref var p = ref p.Value;
    return ~(ж<Pt>)(uintptr)(p);           // CS0030: cannot convert 'Pt' to 'uintptr'
}
internal static Pt derefStruct(ж<Pt> p) {
    return ~p;                            // the box, dereferenced in place
}

pointerReinterpretIdentitySource now accepts either source form — the direct pointer, or one unwrapped from unsafe.Pointer(p) — so the identity is intercepted before the bridge is ever considered. Emitting the box is correct for all three uses at once: a value read copies (~Ꮡp, plus the array .Clone() where the element is an array), an lvalue write lands on the real storage ((Ꮡp).Value = …) rather than on the round-trip’s copy, and pointer identity is preserved. Note the recognition must stay pinned to a genuine (*T)(…) conversion — its Fun must denote a type. Matching on argument type alone collapses any one-argument call that takes and returns the same pointer type, silently deleting it (advance(a)a, Ꮡp.Swap(Ꮡa)Ꮡa); CNR caught exactly that across 13 behavioral projects. The corpus-wide A/B footprint is two lines in one filetime.NewTimer/AfterFunc’s (*Timer)(newTimer(…)), where newTimer already returns *Timer, shed a redundant identity cast — because the CS0030 shape needs a pointer parameter, which the stdlib’s own reinterprets never use; the defect bites converted end-user code and behavioral guards. (Guarded by the TypedPointerCastDeref behavioral output test — struct, named-numeric, via-unsafe, non-deref, lvalue, and local-pointer shapes, plus the one-argument-call over-match control — and by the strengthened ArrayCastDerefClone; both verified to FAIL with the fix neutered, with that exact CS0030.)

Still routed through the bridge (a known gap): a typed-pointer source whose element type differs but shares an underlying — Go permits (*T)(p) there — is only partly covered by the named↔named / named↔basic / named↔array re-box routes below. A tag-differing struct pair (types.Identical counts tags, Go’s conversion rule does not), an unnamed-array ↔ named-array pair, and a named ↔ unnamed struct pair all fall through to the raw-address bridge and mis-render. None occurs in the stdlib corpus, and narrowing the bridge gate without a correct box→box route for them merely trades one broken form ((ж<Row>)(uintptr)(p)) for another ((ж<Row>)p), so the gate is left as-is and the shapes are recorded here.

A deref whose starred inner is a func type (or any non-identifier type) — *(*func())(add(…)), runtime panic.go’s deferred-slot read return *(*func())(add(p.slotsPtr, i*…)), true — misses the identifier-gated cast-deref branch and falls to the default deref path, which must wrap the cast before .Value: C# postfix binds tighter than a cast, so a naked .Value re-binds onto the cast’s inner operand ((ж<Action>)(uintptr)(add(…)).Value reads the inner @unsafe.Pointer’s uintptr — CS0029 ж<Action>Action in the tuple return). The default deref now wraps any type-conversion operand: (((ж<Action>)(uintptr)(add(…))).Value, true). This is the fourth instance of the cast-precedence/extra-paren family, and indexing a reinterpret result directly is the fifth: (*[2]uint64)(x)[0] = 0 (runtime malloc.go) appended the pointer-to-array auto-deref .Value and the index to the cast render — (ж<array<uint64>>)(uintptr)(x).Value[0] read the inner @unsafe.Pointer’s uintptr and indexed a nuint (CS0021); the index emission now wraps a type-conversion base the same way: ((ж<array<uint64>>)(uintptr)(x)).Value[0]. (Guarded by the UnsafePointerReinterpret extensions — the func-type deref in a tuple return and the indexed reinterpret write/read.)

The unsafe builtins unsafe.Add, unsafe.Slice, and unsafe.String accept a length/offset of any integer type (Go’s IntegerType constraint, which includes uintptr/uint). golib’s implementations therefore take a generic IBinaryInteger length, truncated to the int offset — so unsafe.Slice(p, uintptrLen) binds without an explicit cast (a plain nint parameter rejected a uintptr/uint argument with CS1503). (Guarded by UnsafeBuiltinIntegerLen.)

Passing an unsafe.Pointer argument to an unsafe.Pointer parameter keeps the @unsafe.Pointer struct value — add(p, x), not add(p.Value, x). The struct is an exact match for the parameter. (Guarded by UnsafePointerArgPassing.)

Array-backed defined types reinterpret through storage-sharing Value refs, not value copies. The fiat field-arithmetic shape (crypto/internal/edwards25519 scalar.go) reinterprets &s.s (a fiatScalarMontgomeryDomainFieldElement, written directly over [4]uint64) as (*[4]uint64) — and as its sibling (*fiatScalarNonMontgomeryDomainFieldElement) — then writes element-wise through the reinterpreted pointer (fiatScalarFromBytes parses INTO &s.s on a virgin receiver). Neither the copy-boxing named↔named route (each [GoType("[N]elem")] wrapper converts only to array<E>; a sibling cast needs two chained user conversions — CS0030) nor a plain ж<> cast (distinct instantiations) works, and any copy-based route would materialize the wrapper’s lazy backing on a temp and orphan every write. The emission derefs through the ref-returning ж<T>.Value and invokes the wrapper’s Value property in place — Ꮡ((Ꮡs.of(Scalar.Ꮡs)).Value.Value) (underlying-array form) / Ꮡ((nonMont)((…).Value.Value)) (sibling form, one implicit conversion from array<E>) — materializing the backing on the ORIGINAL storage and boxing an array<E> struct that shares its T[]: element reads and writes flow through. Gating consults the type’s written RHS (a new per-package pre-pass records each TypeSpec’s declared right-hand side, which Named.Underlying()’s full resolution loses): only types written directly over an unnamed array take this route, so chain-defined view wrappers (type pallocBits pageBits) keep the existing copy-box route byte-identically; the same written-RHS gate lets isTypeConversion claim the pointer-to-type-literal target (*[4]uint64)(…) (no types.Object exists for a composite type) without disturbing the pointer-cast slice form ((*[1<<20]Method)(p)[:n:n], internal/abi). Caveat (documented, no stdlib site): a whole-value write through the reinterpreted box (*p = q) rebinds only the boxed struct. (Guarded by the NamedArrayWrapper extensions — a virgin-field write through the underlying reinterpret, a sibling reinterpret aliasing the same storage read-during-write, and a heap-boxed local, all output-compared vs Go.)

The uintptr → ж<T> raw-address reinterpret operator is explicit by design. It boxes a copy of the value read at an arbitrary address (the runtime-unsafe reinterpret seam) — never something to happen silently, and every converter-emitted reinterpret already uses explicit cast syntax ((ж<T>)(uintptr)(p)). As an implicit conversion it also poisoned overload resolution: a uintptr argument converted to both an @unsafe.Pointer parameter (via the numeric uintptr ↔ Pointer operators, which stay implicit) and any ж<T> parameter, so a free function and a same-named pointer-receiver method — runtime’s func add(p unsafe.Pointer, x uintptr) (stubs.go) vs func (p *notInHeap) add(bytes uintptr) (malloc.go), both emitted as static add overloads in the package class — were ambiguous (CS0121) at every free-call site whose argument is a pin of a boxless receiver: inside a [GoRecv] ref method, unsafe.Pointer(b) emits the uintptr-typed (uintptr)@unsafe.Pointer.FromRef(ref b) (runtime map.go b.keys()/b.overflow()/b.setoverflow(), mprof.go’s stack-record walkers — 6 sites). With the operator explicit, the uintptr argument binds only the @unsafe.Pointer overload. The reverse ж<T> → uintptr (box → address) operator remains implicit — producing a number is not a silent deref. (Guarded by the FuncVsMethodOverload behavioral output test — the free add + direct-ж method add overload pair with the boxless-receiver pin call shape, plus both method-call forms, values vs Go; cleared all 6 runtime CS0121, 59 → 53.)

A cross-package type reference emits its using <alias> = <namespace>; even when the file did not import the package under a usable name. A foreign type renders in short-alias form — pkg.Type (time.Duration, abi.Kind) for a named type, @unsafe.Pointer for the unsafe.Pointer basic — which resolves only through a file-local alias (using time = time_package;, using @unsafe = unsafe_package;). That alias is normally generated from a canonical (unaliased) import, but a file can reference a foreign type with no such import through three routes: type inference — a same-package function returns a foreign type, so the caller infers a local of that type but never writes pkg. and need not import the package (runtime preempt.go: fd := funcdata(f, i), where funcdata returns unsafe.Pointer); a blank import (_ "pkg", side-effects-only — no using is emitted for it at all: the old using _ = <ns>; emission hijacked C#’s _ DISCARD for the whole file, so a deconstruction discard ((w, _) = w.ensure(…), runtime tracetime.go) bound the namespace alias instead (CS0118 + CS0029); the import is recorded as a comment, and a genuine type reference still gets its canonical alias from this machinery — e.g. symtabinl.go’s _ "unsafe" for //go:linkname); or an aliased import (import u "unsafe", whose alias u differs from the canonical pkg.Name() prefix the type reference uses). All previously yielded CS0246. The converter now walks every emitted type (collectTypePackages, called from getAliasQualifiedTypeName — named types by pkg.Path(), an unsafe.Pointer basic by the pseudo-path "unsafe", recursing through pointer/slice/array/map/chan/generic/func-signature so a []time.Duration element registers too) and, at file close (visitFile), supplies the canonical using <alias> = <namespace>; for every referenced foreign package the file did not already import canonically. It is idempotent-safe — a canonical import records its path in canonicalAliasImported, so visitFile never re-emits (duplicates) it — and a non-canonical alias (using u = unsafe_package;) coexists with the added canonical one without conflict. It is also collision-guarded: the synthesized using <alias> = <namespace>; is skipped when its canonical <alias> was already bound to a different namespace by a real import — cryptobyte’s asn1.go imports both encoding_asn1 "encoding/asn1" (referenced by type, so it reaches this loop) and the subpackage .../cryptobyte/asn1 (unaliased → alias asn1), so synthesizing using asn1 = encoding.asn1_package would duplicate the subpackage’s using asn1 (CS1537). The real imports’ emitted aliases are tracked per file (importAliasesEmitted); the parent stays reachable through its encoding_asn1 alias, so skipping the canonical one is safe (a non-colliding canonical alias is still supplied — no churn). (The separate defect that the type reference itself renders asn1.ObjectIdentifier rather than the file’s encoding_asn1.ObjectIdentifiergetAliasQualifiedTypeName uses the canonical alias, not the file’s non-canonical one — is tracked independently.) This is the type-reference analog of the method-call addMethodPackageNamespaceUsing. (Guarded by UnsafePointerInferredNoImport — the unsafe.Pointer basic arm, scalar/composite/blank-import variants — and InferredForeignTypeNoImport — the generic named arm, an inferred *strings.Reader in an fmt-only consumer.)

That supplied alias must carry the collision rename. When the referenced package’s using alias is Δ-renamed because a same-named CHILD namespace is visible from the import closure (go.sync, contributed by sync/atomic; go.unicode, by unicode/utf8 — the same CS0576 collision that renames a canonical import’s alias, above), getAliasedTypeName already renders the short-form type reference through the renamed qualifier (Δsync.Mutex, Δunicode.Range16). The visitFile supply loop, however, composed the alias from packageUsingAlias alone — the bare, unrenamed name — so it emitted using sync = sync_package; (or using unicode = unicode_package;) while the reference read Δsync.Mutex: the alias binds nothing (CS0246), and the bare alias would itself collide with the child namespace (CS0576). The supplied alias is now routed through importQualifier (getSanitizedImport(importQualifier(alias)), the same rename every canonical import applies), so the emitted using Δsync = sync_package; matches the reference. importQualifier is a no-op for any package whose alias is not renamed, so a non-colliding supplied alias stays byte-identical. The trigger is a file that reaches a renamed package’s type through the supply route rather than a canonical import — overwhelmingly a dot import (. "sync" / . "unicode"), where the dot brings names in via using static yet the converter still qualifies the type, and no canonical using <pkg> = … is emitted to carry the rename. Production stdlib code essentially never dot-imports, so the defect stayed latent as an unused supplied alias (reflect’s value.go/makefunc.go inferred sync without importing it — the using sync alias was never referenced, so the wrong spelling compiled); it surfaces in an EXTERNAL (_test) variant that dot-imports the package under test, which unicode’s letter_test.go does (. "unicode" + qualified Range16/RangeTable/CaseRange). Because every currently-compiling site had the alias unused, the change only ever flips an unused alias (compile-neutral) or fixes a broken one — no site that used Δpkg. while getting the bare supplied alias could have compiled. (Guarded by the DotImportRenamedPackage behavioral test — . "sync" with a *Mutex type reference forcing the qualified Δsync.Mutex position, output-compared vs Go; neutering the fix reproduces the reported CS0246: 'Δsync' could not be found.)

uintptr(unsafe.Pointer(x)) builds no Pointer object — the operand converts straight to uintptr (2026-08-03, r39c). Go’s most common syscall idiom converted to (uintptr)new @unsafe.Pointer(x), and that object was provably dead. golib’s Pointer is a ж<uintptr> whose only value-taking constructor takes a uintptr, so the operand is ALREADY converted — by implicit operator uintptr(ж<T>), the very operator the enclosing cast would use — before the wrapper exists; the wrapper stores that finished number in its own one-element slot, and the cast reads it straight back out. The round-trip is the identity, exactly: uintptr(Pointer) returns value.IsNull ? 0 : value.Value, and the constructor marks the box nil precisely when the address is 0 (base(value, value == 0), with ж<uintptr>’s value-peeking IsNull arm unable to fire for a value-typed pointee). So the wrapper is no longer emitted:

r1, _, e1 := Syscall(procConvertSidToStringSidW.Addr(), 2, uintptr(unsafe.Pointer(sid)), uintptr(unsafe.Pointer(&stringSid)), 0)
var (r1, _, e1) = Syscall(procConvertSidToStringSidW.Addr(), 2, (uintptr)sid, (uintptr)stringSid, 0);

The pin is what makes this safe to elide, and it is not the wrapper’s. Since r38 every managed address handed to native code is pinned for the POINTER’s lifetime (EVERY managed address handed to native code is pinned…, above) — but EnsureStableAddress / pinnedArrayData set m_pin on the operand box, released when the operand is collected. The Pointer wrapper owns no pin, holds no reference to the operand, and tracks no lifetime; its ж<uintptr> slot holds a number. Eliding it therefore cannot shorten any pin, move any address, or change any value — a strictly-dead allocation, which is why this is a peephole and not a semantic change.

The rule is keyed at the wrapper’s own emission site (unsafePointerBoxEmission, marked per-CallExpr by markDeadUnsafePointerBox from the enclosing conversion), so it is self-limiting: the unsafe.Pointer arms that render a raw address by other means — @unsafe.Pointer.FromRef(ref x) for a deref-aliased pointer receiver, ((@unsafe.Pointer)(uintptr)v) for a named uintptr/pointer operand, and (@unsafe.Pointer)default! for the literal nil — never consult the mark and are byte-identical. The enclosing conversion must be one that renders a (uintptr) cast around the operand, which is the basic uintptr(…) target and the named-over-uintptr target that hops through its underlying (Handle(…), syscall/security_windows.cs’s LocalFree defers). An operand that binds looser than a cast is parenthesized (unsafe.Pointer(uintptr(p) + off)); an address-of operand is not, because &x renders as the primary box form Ꮡx / Ꮡ(…) / Ꮡs.at<T>(i).

Corpus footprint: 494 sites across 45 files in a seeded two-temp-root A/B whole-stdlib reconvert — 249 in syscall/zsyscall_windows.cs alone, 65 in internal/syscall/windows/zsyscall_windows.cs, 25 each in runtime/heapdump.cs and runtime/os_windows.cs — plus 10 across four behavioral goldens; every changed line in the A/B is reproduced exactly by the transformation and none is anything else. Measured with a GC.GetAllocatedBytesForCurrentThread probe over 2,000 calls: syscall.Write 1,072 → 544 B/op (−49.3 %) and os.File.WriteString 9,208 → 8,680 B/op, i.e. 528 bytes — three wrapper objects — off every zsyscall wrapper call chain. (The hand-owned crypto/subtle/xor_generic.cs keeps its three sites, as it must: the converter regenerates it into the xor_generic.cs.auto review sibling instead of over it.) (Guarded by the UintptrUnsafePointerIdiom behavioral output test — address identity across two takes of one global, a pointer parameter, the nil pointer’s 0, an array-element stride, an existing unsafe.Pointer value round-trip, a dereferenced pointer-to-pointer operand, and a write read back through the same pointer, all vs go run — plus the re-baselined NilPointerUintptr, NilPointerParamUnsafePointer, FixedArrayBufferPointer and UnsafeOperations goldens, which diverge the moment the peephole is neutered.)

Pointer DISPLAY never dereferences out-of-range; unsafe.StringData("") is nil

Printing a pointer (ж<T>.ToString()PrintPointer, the stub-fmt fallback for %v/%p of a pointer) only needs an address-like 0x… token, but the printer read ptr.Value to derive one — and an array/slice-ELEMENT reference can legally sit outside its backing store’s valid range (the zero index of an EMPTY pinned buffer, or one-past-the-end pointer arithmetic), where that read throws IndexOutOfRangeException and kills the host (strings’ TestClone, Phase-4 row R9). PrintPointer now checks an element reference’s index against its backing store first and prints the BACKING STORE’s identity when the element is unreadable — stable per pointer box, never a throw. Relatedly, unsafe.StringData of an EMPTY string now returns nil: Go documents the empty-string result as unspecified-may-be-nil, its runtime returns nil (probed — so distinct empty strings’ data pointers compare EQUAL, which TestClone asserts), and golib’s pin-a-fresh-buffer-per-call implementation could never satisfy that identity. Addresses differ run to run, so behavioral coverage checks printed SHAPE and nil-identity (UnsafePointerPrint); the out-of-range print itself has no Go-parity spelling from converted code today (the unsafe.Add-through-unsafe.Pointer seam loses the element box), so that property is guarded at the golib level by GolibTests.PointerPrintTests — a golib UNIT-test project (beside ChannelTests under /tests/library/) for runtime properties no Go↔C# output comparison can reach.

unsafe.String(ptr, 0) reads nothing, so it must not pin ptr either. That same empty-buffer element reference reaches the string builtin, by a route the standard library takes constantly on Windows: syscall.UTF16ToString truncates its argument at the first NUL and returns unsafe.String(unsafe.SliceData(buf), len(buf)), so an all-NUL [N]uint16 — every unset WCHAR field of a Win32 record (WIN32_FIND_DATAW.cAlternateFileName on a volume with 8.3 name generation disabled, MIB_IFROW.wszName, PROCESSENTRY32.szExeFile, STARTUPINFO.lpDesktop) — arrives as a zero-length buf. SliceData is documented to return a non-nil pointer to an unspecified address for a non-nil slice of capacity 0 (only a nil slice yields nil), which this model materializes as an index-0 box into a zero-length backing array; unsafe.String then pinned that referent (fixed (byte* p = &ptr.Value)) to build the string and threw IndexOutOfRangeException where Go returns "". The zero-length case now returns the empty string before the pointer is touched, which is precisely Go’s rule: a run-time panic occurs only when ptr is nil and len is not zero, so a length of zero dereferences nothing whatever the pointer is. SliceData is deliberately left alone — Go specifies the non-nil-pointer-to-unspecified-address result for cap == 0, so returning nil there to dodge the deref would trade a throw for a wrong answer. unsafe.Slice(ptr, 0) needs no matching guard: its element-window route (TryGetElementWindow) already yields an empty aliasing window over a zero-length backing without a deref. (Guarded by the UnsafeStringEmpty behavioral output test — the all-NUL [14]/[260] buffers, terminated/unterminated/leading-NUL controls that keep truncation honest, and the SliceData/StringData zero-length matrix with non-zero-length positive controls, compared vs go run; verified to throw with the guard removed.)

unsafe.SliceData is an INTERIOR POINTER, not a pin. Go defines it as &slice[:1][0], so the faithful model is the array-element reference Ꮡ(s, 0) — the exact box the converter emits for &s[0]. It was instead a pinned-buffer box over slice.buffer, and that was wrong three ways at once:

The pin The consequence
GCHandle.Alloc(…, Pinned) refuses storage whose element type carries a managed reference SliceData over any such slice threw ArgumentException: Object contains references. log/slog’s GroupValue is the corpus witness — groupptr(unsafe.SliceData(as)) over []Attr, rebuilt by unsafe.Slice in Value.group() — and it infrastructure-errored every grouping path in the package (5 testing/slogtest rows).
The pin covered the whole backing array from index 0, ignoring the slice’s LOW bound SliceData(s[2:]) addressed element 0 rather than element 2, and did not compare equal to &s[2] as Go’s pointer identity requires.
PinnedBuffer implements IArray<byte> alone ж<T>.Value’s array is IArray<T> test failed for every element type but byte, so the derived pointer was undereferenceable — InvalidOperationException instead of the element.

Pinning was never what SliceData means. An address is needed only when the pointer is converted to uintptr/void*, and ж already pins on demand at exactly those conversions (EnsureStableAddress), declining gracefully for storage that cannot be held still. The element reference additionally makes the round trip alias rather than snapshot — unsafe.Slice’s TryGetElementWindow arm rebuilds a window over the original backing, so a write through the rebuilt slice reaches the source, which is Go’s semantics. StringData keeps its pin: a @string is a byte[], always pinnable, and its emptiness identity is the property TestClone asserts. (Guarded by the UnsafeSliceDataAliasing behavioral output test — a []struct{string;int} round-tripped through SliceData/Slice and written through, SliceData(s) == &s[0] identity, a re-sliced source’s low bound, an []int64 and a []*T deref, and the plain []byte control — plus UnsafeStringEmpty, which pins the zero-length behavior above unchanged.)

Reinterpreting a pointer to a defined type with identical underlying — (*Base)(p)

A Go conversion (*Base)(p) where p is a *Def and Base/Def share an identical underlying type (one is a defined type over the other, e.g. type pinnerBits gcBits, or both over the same type) reinterprets the pointer. C# has no conversion between the two distinct generic instantiations ж<Def> and ж<Base>; only the [GoType] wrapper’s value conversion Def ↔ Base exists. So the converter performs the reinterpret on the value and re-boxes it:

func (s *mspan) newPinnerBits() *pinnerBits { return (*pinnerBits)(newMarkBits(s.nelems * 2)) }   // newMarkBits returns *gcBits
internal static ж<pinnerBits> newPinnerBits(this ref mspan s) {
    return ((pinnerBits)(~newMarkBits(((uintptr)s.nelems) * 2)));   // deref the ж<gcBits> box, value-convert, re-box
}

The argument is dereferenced first (~box) when it renders as a genuine pointer box — a call result, a local box, or a pointer field — because the value conversion operates on the underlying value, not on ж<Def> (a plain (pinnerBits)(ж<gcBits>) is CS0030). A deref-aliased pointer parameter/receiver already renders as the pointed-to value (Δp, not a box), so it value-converts directly with no ~ — the original (*atomic.Uint32)(p) receiver case (runtime/mprof goroutineProfileStateHolder). Both forms box a copy (): the shared underlying is the wrapped value, and a defined-over-struct wrapper holds it in a readonly field, so there is no write-through to lose; this matches the long-standing copy semantics of this branch (the runtime intrinsics behind these are assembly stubs). Both ships stay in managed ж<> land — no raw-address round-trip. (Guarded by NamedPointerReinterpret.)

The third direction — a pointer to a BASIC type reinterpreted to a defined type over that basic — takes the same value-convert-and-re-box route: fmt’s (*stringReader)(&str) (type stringReader string) emits Ꮡ((stringReader)(str)) — the address-of collapses with the value deref, restricted to this arm so the long-guarded emissions stay byte-identical. Writes through the box hit the copy, which is faithful for the pattern (the source string is never re-read). Guarded by NamedPointerReinterpret (tail/consume). The deferring-receiver rule is a sibling of these box-form decisions: a method that defers or recovers at FUNCTION level and also references its receiver takes the direct-ж receiver (this ж<T> Ꮡx) rather than this ref T, whose deref alias then emits inside the frame’s try (bodyWrappedInDeferContext; fmt ss.Token, guarded by DeferCallOrder acc.add). The direct-ж form is the alloc-free, race-free one, and it is also what a deferred closure needs, since a lambda cannot capture a ref local.

The same block also covers a named-numeric pointer reinterpreted to its underlying basic type(*uint64)(head) where head is a *lfstack (type lfstack uint64). This is the runtime’s atomic-on-a-named-integer pattern: atomic.Load64((*uint64)(head)) / atomic.Cas64((*uint64)(head), …) on the named atomic types lfstack (uint64, lfstack.go), sweepClass (uint32, mgcsweep.go), profAtomic (uint64, profbuf.go), and sysMemStat (uint64, mstats.go). ж<lfstack> and ж<uint64> are distinct generic instantiations with no conversion (CS0030); the reinterpret condition is generalized from Named↔Named to also fire when the result elem is a basic type whose underlying equals a named argument elem’s (namedToBasic), and again for the reverse (basicToNamed).

These three arms now ALIAS instead of boxing a copy (2026-08-03, r38-gob)

Everything above described the emission as value-convert-and-re-box, and justified the copy each time the shape came up — “no write-through to lose”, “the intrinsics are asm stubs”, “the source string is never re-read”. That justification was a property of the call sites the arm happened to have, not of the conversion, and Go’s (*U)(p) says the opposite: the derived pointer names p’s own storage, so a write through it is visible through p. encoding/gob is where the difference stopped being theoretical. Gobber.GobDecode decodes straight back through a reinterpret used as a call argument

type Gobber int
func (g *Gobber) GobDecode(data []byte) error {
	_, err := fmt.Sscanf(string(data), "VALUE=%d", (*int)(g))   // writes THROUGH the reinterpret
	return err
}

— which is neither a deref context nor a raw-address source, so it took this arm and emitted fmt.Sscanf(…, Ꮡ((nint)(g))): Sscanf’s write landed in a throwaway box, g never changed, and the decoder returned 0 for 23. All three arms now route through golib’s aliasing reinterpret — reinterpretManagedEmission, the same emission this file already produced for the identical conversion in a deref or raw-address context — so the arm’s own gate is the only thing that changed:

public static error GobDecode(this ж<Gobber> g, slice<byte> data) {
    var (_, err) = fmt.Sscanf(((@string)data), "VALUE=%d"u8, g.Reinterpret<Gobber, nint>());
    return err;
}

PointerExtensions.Reinterpret is where the “can the managed model express this alias?” decision already lives (ReinterpretAliasesStorage), so the converter delegates rather than re-deciding; it reports false for the two shapes that must keep the re-box — an IDENTITY conversion (intercepted upstream by pointerReinterpretIdentitySource) and an array pointee, whose lazily-materialized backing store a storage reinterpret bypasses — so the chain-defined type pallocBits pageBits pair the written-RHS gate deliberately leaves on this arm falls through unchanged. A useful second-order effect: the entry deref alias these functions carried (ref var head = ref Ꮡhead.DerefOrNull();) becomes dead, because the body now names only the box, so the alias-liveness scan drops it.

Whole-stdlib A/B (converter-vs-converter, both sides seeded per ritual 1a): 14 files, 41 hunks, every one this substitution and nothing else — and the census is what shows the copy was never harmless. Beyond the runtime’s asm-stub atomics (lfstack, sweepClass, profAtomic, sysMemStat, pinnerBits), it silently broke real write-through in flag (every newBoolValue/newIntValue/… returns (*boolValue)(p), so a parsed flag never reached the user’s variable), crypto/tls (clientShares.ReadUint16((*uint16)(&ks.group)) parsed key-share groups and signature schemes into a copy), crypto/cipher ((*cbcEncrypter)(newCBC(b, iv)), whose IV mutates per block), image/png ((*encoder)(buffer) over a pooled EncoderBuffer), go/types ((*term)(t)), and crypto/internal/boring/bbig. The reconverted corpus builds 304/304, 0 errors. (Guarded by the extended NamedNumericPointerReinterpret behavioral output test: the read path it always covered, plus write-back through an argument-position reinterpret — the gob shape — a named→named struct reinterpret held in a local and written through a field selector, and a basic→named (*namedString)(&s); verified to FAIL on stdout with the fix neutered.)

The club-41 mop-up batch (flag/flate/binary/syntax roots)

Nine coupled rules from the shallow-stack campaign:

Slice-to-array: the VALUE form copies, the POINTER form ALIASES

Go has two slice-to-array conversions and they are different conversions, so each gets its own golib entry. Both panic Go-style on a short slice.

This was a copy until 2026-07-31, and the copy was a silent wrong answer, not a performance detail — every write through the pointer was discarded. image/png’s encoder is the corpus witness: its cbTCA8 row loop converts each four-byte destination window and writes the un-premultiplied pixel through it,

d := (*[4]byte)(dst)
s := (*[4]byte)(src)

d[0] = uint8((uint32(s[0]) * m / a) >> 8)

so against a copy every pixel write went nowhere and any RGBA image that was not fully opaque encoded as an all-zero image. It surfaced as TestWriteRGBA’s “50/50 Transparent/Opaque RGBA” and “RGBA with variable alpha” subtests, and the two subtests that did pass passed by luck: the opaque one takes the cbTC8 path entirely, and the fully-transparent one wants all-zero output, which is what a lost write also produces.

The window is where the aliasing is, not merely the sharing: the png loop converts cr[0][1:] and re-slices it forward, so the array’s element 0 is not element 0 of the backing store. Three consequences follow and all three are Go’s:

Guarded by SliceToArrayPointerAlias (offset write-through, read-back through the pointer, value-conversion copy, deref copy, p[:] aliasing and len/cap, element identity, and the png-shaped windowed loop) and by NamedPointerReinterpret (sliceToArray). The POINTER-sourced sibling (*[N]T)(unsafe.Pointer(p)) is below.

An element pointer reinterpreted as an array pointer ALIASES the element’s storage

(*[N]T)(unsafe.Pointer(p)) with p a *T is the same conversion reached from a POINTER instead of a slice, and it emits the same kind of window — array<T>.AliasPointer(p, N), which yields the ж<array<T>> directly. It is what Go’s stdlib reaches for whenever a *T really names a run of elements: os’s TestReadStdin fake fills internal/poll’s read buffer with copy((*[10000]uint16)(unsafe.Pointer(buf))[:n:n], s16), and syscall.Readlink decodes a reparse record through (*[0xffff]uint16)(unsafe.Pointer(&data.PathBuffer[0]))[:n:n].

Before this the shape took the raw-address route ((ж<array<T>>)(uintptr)(…)), which cannot express it in either of its two forms. Dereferenced, that box reads an array<T> STRUCT — a backing-store reference plus bounds — out of the pointed-at data, i.e. a fabricated managed reference. Sliced, the isPointerCast fusion in convSliceExpr catches it first and produces a slice<T> COPY of the memory, so every write through it is discarded: all 462 of TestReadStdin’s subtests read back zeros (have [0 0 0…] want [abc…]).

Three rules bound the arm, and each is a real limit of the managed model rather than a convenience:

One latent defect in the same family fell out with it: SliceExtensions.slice(this array<T>, …) sliced the RAW backing store, ignoring m_low/m_length, so explicit bounds over ANY window — Alias’s as well as AliasPointer’s — addressed the source’s elements rather than the array’s (p[1:3] of a window over buf[1:] yielded buf[1:3], not buf[2:4]). The Range indexer already resolved through the window; the explicit-bounds path now does too, which is the path the [:n:n] idiom takes.

Guarded by ArrayPointerElementAlias (write-through at element 0 and at an offset, the huge-N [:n:n] copy shape, indexed write-through, read-back through the pointer, deref copy, p[:] and p[1:3] aliasing with len/cap, element identity, a Go fixed-array source, and a re-sliced view).

A direct-ж method on a value field-chain boxes through the &-machinery

A direct-ж (box-receiver) method called on a field of a plain VALUE param — netip’s ip.addr.halves(), where Go auto-addresses &ip.addr — routes the receiver through the &-machinery: Ꮡ(ip).of(ΔAddr.Ꮡaddr).halves(). This boxes a COPY, which is faithful because the enclosing Go value param is itself a copy: writes through the method could only ever reach the local copy in Go too. (Pointer-rooted chains and indexed elements take their own long-standing arms; this is the remaining value-rooted case.) Guarded by StructPointerPromotionWithInterface (rig/probeRig).

Field address of a collision-renamed heap-boxed local uses the raw box name

A heap box always keeps the RAW Go identifier (ref var Δslice = ref heap<T>(out var <box>slice)), so taking the address of a FIELD of a collision-renamed boxed local routes through boxBaseName – the raw-name box, never the Δ-renamed alias (CS0103; reflect SliceOf’s &slice.Type). This matches the whole-value &p form and the renamed receiver/parameter boxes:

internal static void bump(ж<nint> np) {

Guarded by CollisionRenamedLocalBox (bump(&p.n) on the renamed local p).

A capture-mode method on a shadow-renamed heap-boxed local uses the rendered box name

A capture-mode method — one that escapes its receiver’s address, e.g. cryptobyte.Builder.AddASN1, which hands &b to a callback — called on a heap-boxed VALUE local routes through the receiver box: var b Builder; b.AddASN1(…)Ꮡb.AddASN1(…). Unlike a deref-aliased pointer parameter (whose box keeps the RAW name, Ꮡp), a heap-boxed value LOCAL keeps its box under the RENDERED name — an escaping local is ref var b = ref heap(new T(), out var Ꮡb), so when the local is SHADOW-renamed its box takes the renamed name. crypto/x509 marshalCertificate’s inner serialiseConstraints closure declares var b cryptobyte.Builder, renamed bΔ1 to dodge the enclosing method’s own var b declared LATER (a C# lambda cannot re-declare an enclosing-scope local, CS0136); its box is ᏑbΔ1. Emitting the raw-name box Ꮡb there both mis-references the outer b’s box (declared later in the method → CS0841/CS0103) and, where a same-named outer box does resolve, calls the method on the wrong operand — go/types conversions.go called x.convertibleTo on the receiver box Ꮡx instead of the inner operand box ᏑxΔ2:

ref var bΔ1 = ref heap(new cryptobyte.Builder(), out var bΔ1);

bΔ1.AddASN1(cryptobyte_asn1.SEQUENCE, (ж<cryptobyte.Builder> bΔ2) => {  });   // was Ꮡb (CS0841)

The receiver-box render resolves the box base through boxBaseName with the lambda capture-remap DISABLED, so it yields: the shadow-rendered declaring name (bΔ1) for an escaping local; the raw name (Ꮡp) for a pointer parameter; and — critically — the declaring name for a variable CAPTURED by the closure, not its value-snapshot capture name. A heap-boxed local captured by a closure has its box captured directly (Ꮡonce in sync OnceFunc’s returned closure), so the capture-remapped Ꮡonceʗ1 (a non-existent box) must not appear. Guarded by ShadowedHeapBoxReceiver (an inner closure’s var b capture-mode method, shadow-renamed against an outer same-named var b declared later).

Nested dereferences parenthesize before the outer .Value

A deref whose operand is ITSELF a deref renders with the prefix ~ form, on which a naked postfix .Value mis-binds (postfix beats unary: ~X.Value is ~(X.Value)). The outer deref wraps the inner one – reflect MapOf’s **(**mapType)(unsafe.Pointer(&imap)):

var back = (~(ж<ж<array<int64>>>)(uintptr)(@unsafe.Pointer.FromRef(ref (ip).Value))).Value;

Guarded by PointerCastSliceRange (compile-shape).

Function literals returning unsafe.Pointer state their return type

A literal with a single unsafe.Pointer result can mix return arms of DIFFERENT C# types (reflect deepEqual’s ptrval: (uintptr)v.pointer() on one arm, the raw v.ptr on the other), which defeats C# lambda return-type inference (CS8917). The emitted lambda states its return type explicitly; each arm then converts implicitly through the golib operators:

var pick = @unsafe.Pointer (bool u) => {

Guarded by PointerCastSliceRange (compile-shape).

Interface-returning literals with distinct arm types state their return type too

The same inference gap hits an interface result whose arms return DIFFERENT concrete types — net ipsock.go’s inetaddr := func(ip IPAddr) Addr returns three pointer-adapter classes (TCPAddrжΔAddr / UDPAddrжΔAddr / IPAddrжΔAddr), which share only the interface (CS8917). When a single non-empty-interface result’s return arms carry two or more distinct types, the lambda states the return type explicitly (Addr (IPAddr ip) => …); each arm then converts implicitly. Single-typed literals keep the inferred form (zero churn). (Guarded by the InterfaceCasting extension makeAnimal — an adapter arm plus a value arm, runtime-verified.)

And the opposite end of the same gap: arms that are ALL untyped nil. client := func(*TCPConn) error { <-serverDone; return nil } (net net_test) renders its only arm as default!, which carries no natural type at all — so where the rule above has too many candidate types, this has none, and it is the same CS8917. The interface arm now also states the return type when the literal is in assignment position, has a return, and every single-result arm is untyped nil. Drift-free by construction: an all-default! arm set never had an inferable natural type, so every site the rule touches is a site that did not compile. It is the single-result twin of the multi-result !hasFullyTypedArm rule immediately below, and it stays out of argument/return position for the same reason that one does — those literals are target-typed by their delegate, so nothing is inferred. (Guarded by the FuncLitStringConcatReturn extension: an all-nil literal in both := and var form, plus a mixed-arm control that must KEEP inferring.)

Multi-value literals with no fully-typed arm state their return type — named results included

The single-result inference gaps above generalize to any MULTI-result literal where EVERY return arm carries a typeless element — return nil, nil, nil, nil, err on the error arms and return dnsNames, ips, emails, uriDomains, nil on the success arm (crypto/x509 parseNameConstraintsExtension’s getValues := func(subtrees) (dnsNames []string, ips []*net.IPNet, emails, uriDomains []string, err error)). A C# tuple literal with any untyped element has no natural type, so no arm fixes the lambda’s return type and delegate-type inference fails (CS8917). The lambda states its tuple return type explicitly, and each nil then takes its target element type:

var getValues = (slice<@string> dnsNames, slice<ж<net.IPNet>> ips, slice<@string> emails, slice<@string> uriDomains, error err) (cryptobyte.String subtrees) => {  };

NAMED results are now included (they were previously excluded): the trigger — a multi-result literal with a return arm but NO fully-typed arm — is identical whether the results are named or not. A bare return (which returns the named results) never matches the result arity, so it neither marks has-return nor a false fully-typed arm; a named literal that DOES have a fully-typed explicit arm keeps inferred typing (no return-type prefix, no churn). Guarded by NamedResultLambdaInfer (a five-result named-result closure whose error arms return nil,nil,err and success arm e,o,nil).

String-returning literals in assignment position state their return type

A literal with a single Go string result can mix return arms of DIFFERENT C# types even though every arm is a Go string: a bare string literal is a "…"u8 ReadOnlySpan<byte>, a literal+variable concat binds golib’s operator +(@string, @string) (so it is @string regardless of u8 suppression), and a call into a hand-written stub can return C# string (the baseline fmt.Sprintf does). @string and string convert implicitly in BOTH directions, so a lambda mixing those arms has no unique best common type and its delegate type is not inferable — CS8917 on pick := func(v any) string {…} whose case string: arm returns "string:" + t alongside fmt.Sprintf arms. In assignment position (var pick = …, where C# must infer the delegate type), the lambda states its return type explicitly and each arm then converts to @string in place:

var pick = @string (any v) => {

Argument/return/composite-element literals are target-typed by their receiving delegate type (no inference to fail — and an explicit return type could only add an identity-match constraint against stub delegate types), so they keep the plain form; the Go var declaration form emits an explicit delegate type (Func<@string, bool, @string> pad = …) and is likewise immune. Gated to the basic string kind — a named string type would need its own conversions. Guarded by FuncLitStringConcatReturn (:= literals mixing concat, u8-literal, and stub-Sprintf arms — including a type-switch body and a right-side literal concat — plus the var form; runtime-verified).

The TUPLE-ELEMENT sibling: in a MULTI-result literal, a bare string literal element is worse than typeless — it is wrongly typed. Inside a tuple the literal emits as a bare C# string (u8 spans cannot be tuple elements), so an arm with no nil and no string variable — internal/fuzz fuzzOnce’s return dur, coverageSnapshot, "" (func(entry CorpusEntry) (dur time.Duration, cov []byte, errMsg string)) — counted as “fully typed” in the multi-result scan above and suppressed the explicit tuple return type, letting inference succeed with the wrong element type: the destructured errMsg was C# string, which has no != against a "…"u8 span (CS0019 rather than CS8917). A basic-string constant literal element whose declared result element is Go string now also marks its arm not-fully-typed, so the same explicit-tuple emission fires:

var fuzzOnce = (time.Duration dur, slice<byte> cov, @string errMsg) (CorpusEntry entry) => {  };

and each "" converts to @string in place via target typing. Same assignment-position gate; a literal whose string elements are all variables keeps inferred typing (the full-stdlib A/B footprint was exactly internal/fuzz worker.cs plus two latent-identity repairs, internal/coverage/decodecounter sget and net ipsock.go addrErr, both re-proven green). Guarded by the FuncLitStringConcatReturn extensions fuzzish (named results, != "" on every destructured element) and sget (unnamed (string, error)); the pre-fix converter fails them with exactly CS0019 ×4.

The NUMERIC sibling: an untyped numeric constant literal element against a differently-SIZED declared result element is wrongly typed the same way. The literal emits bare, so the arm infers the literal’s natural C# type — an INT literal is C# int, a FLOAT literal C# double — where the Go result is e.g. int64: net/http ServeContent’s sizeFunc := func() (int64, error) { …; return 0, errSeeker } had no nil/string-literal element on its error arms, counted “fully typed”, and inferred Func<(int, error errSeeker)> — rejected at the serveContent(…, sizeFunc, …) call because delegate types are invariant (CS1662/CS0029/CS1503, and the leaked errSeeker element name rides the inferred tuple). Such an element now also marks its arm not-fully-typed, so the same explicit-tuple emission fires:

var sizeFunc = (int64, error) () => { ; return (0, errSeeker); };

A declared element the literal’s natural type already matches (int32 for an INT literal, float64 for a FLOAT literal) infers correctly and stays inferred, and Go int (C# nint) is deliberately exempt — return 0, err against (int, error) results is pervasive and green today (the element converts implicitly at every use site), so marking it would churn stdlib-wide for no observed defect, the same reasoning that keeps lambdaConstReturnCastType away from signed single results. A SUB-negated literal (return -1, …) is unwrapped and marked the same way. (Full-stdlib A/B footprint: net/http fs.cs sizeFunc — the target — plus three latent same-shape repairs, internal/coverage/decodecounter rdu32 ×3 and net/http h2_bundle allocatePromisedID (both (uint32, error)) and internal/zstd fetchHuff ((uint16, error)). Guarded by the FuncLitNumericTupleReturn behavioral test — the sizeFunc shape and a float64 shape both PASSED to typed function parameters, the -1 arm, and the int/float64-identical controls that must keep inferred typing; the pre-fix converter fails it with exactly the fs.cs trio CS1662/CS0029/CS1503 ×2.)

That Go-int exemption is narrowed, not absolute: it holds only while every numeric arm at the declared-int position is a literal. When such a position carries BOTH a bare-0 arm (naturally C# int) AND a non-literal Go-int arm (i + 1 → C# nint) — and the other tuple slots on the non-literal arms are typeless (default!), leaving a single naturally-typed literal arm to drive inference — C# infers the delegate’s first element as int, so the nint arm then fails to convert (CS0029/CS1662) and the assignment-inferred delegate is rejected at the invariant use site (CS0407). This is bufio ExampleScanner_*’s onComma := func(…) (advance int, token []byte, err error) { …; return i+1, data[:i], nil; …; return 0, data, ErrFinalToken; }. The multi-result scan therefore additionally records, per declared-int position, whether an int-LITERAL arm and a non-literal nint-expression arm both occur; when they do, the explicit (nint, …) return type is forced ((nint advance, slice<byte> token, error err) (…) => …) and each arm converts in place. The all-literal return 0, err shape has no non-literal arm, so it keeps its inferred emission untouched — the corpus is undisturbed (behavioral CNR byte-identical across all 451 projects). (Guarded by the FuncLitNumericTupleReturn extension — the mixedIntArms/onComma shape assigned and passed to a typed parameter, alongside the unchanged all-literal intControl; the pre-fix converter fails it with CS0029/CS1662/CS1503.)

Numeric-returning literals with untyped-constant arms state their return type

The SINGLE-result numeric sibling of the string arm above (2026-07-17; the Phase-4 blocker-map row B7b — strings ×3, bytes ×2): a literal with a declared numeric result whose return arm references a named untyped constant — strings/bytes TestMap’s maxRune := func(rune) rune { return unicode.MaxRune }. The const reference emits as a golib Untyped* wrapper reference (Δunicode.MaxRune, an UntypedInt static), and the wrapper’s implicit conversions run in both directions with every numeric type. So in natural-inference position an all-const arm set infers the wrapper delegate — var maxRune = (rune r) => Δunicode.MaxRune; is Func<rune, UntypedInt>, rejected at the invariant-delegate Map(maxRune, …) call (CS1503) — and a mixed const/typed arm set (TestMap’s encode, mixing unicode.MaxRune/utf8.RuneSelf with the rune parameter) has no unique best common type at all (CS8917). When any top-level return arm is a bare named untyped-const reference, the lambda states the declared return type explicitly and each arm converts in place:

var maxFn = rune (rune _) => maxRune;

Same gates as the string arm: assignment position only (argument/return/composite-element literals are target-typed — no inference to fail), and a BASIC numeric result (a named numeric type would need a second user conversion the wrapper cannot chain — the lambdaConstReturnCastType named-type rationale). Literal-only arm sets stay inferred (no churn): an int literal is already C# int, a rune literal emits (rune)'a', so minRune := func(rune) rune { return 'a' } infers correctly without a prefix.

A constant operator expression arm containing a named untyped constant counts the same as the bare reference (2026-07-17; the B7b gap — bytes TestMap’s invalidRune := func(r rune) rune { return utf8.MaxRune + 1 } was the one remaining bytes build error): the operator result keeps the wrapper type, so the inferred delegate was Func<int, UntypedInt> against Map’s Func<int, int> parameter (CS1503). The arm test (returnArmKeepsUntypedWrapper) walks paren/unary/binary trees for an untyped-named-const leaf, except when a constant fold (overflowingConstLiteral / floatContextConstLiteral) rewrites the whole arm to a plain literal — that emission is concretely typed and needs no prefix. All other gates unchanged. (Guarded by the FuncLitUntypedConstReturn behavioral test — the single-arm CS1503 shape, the mixed-arm CS8917 shape, an int64 result with a beyond-int32 const arm, the const-expression arm (return maxRune + 1), plus literal-only and argument-position controls that must keep the plain form; output-compared vs Go.)

A literal in GENERIC-RESULT inference position states its return type

The arms above all describe natural-inference position — a literal assigned to a var, where no delegate target exists. A literal passed as an ARGUMENT is normally target-typed by its parameter and needs no prefix, and the earlier rules say exactly that. There is one argument shape where that is false: the callee is generic and the parameter’s declared signature returns a type parametersync.OnceValue[T any](f func() T), sync.OnceValues[T1, T2 any](f func() (T1, T2)). There the parameter type is not yet a concrete delegate; C# must infer the type argument from the lambda’s own return expressions, so the Go result type go/types already resolved is ignored. Two shapes then break (both live in sync’s oncefunc_test.go):

A func literal in that position now states its declared result type, which fixes the type argument to exactly Go’s for every arm shape (so, unlike the natural-inference arms, no arm inspection is needed):

var onceValue = sync.OnceValue(func() int { return 42 })
f := sync.OnceValue(func() any { calls++; panic("x") })
g := sync.OnceValues(func() (any, any) { buf[0] = 1; return nil, nil })
internal static Func<nint> onceValue = Δsync.OnceValue(nint () => 42);
var f = Δsync.OnceValue(any () => { calls.Value++; throw panic("x"); });
var g = Δsync.OnceValues((any, any) () => { bufʗ3[0] = 1; return (default!, default!); });

The gate is the result position specifically. A type parameter appearing only in the func-typed parameter’s own PARAMETER list — the slices.SortFunc(x, func(a, b E) int) shape — is inferred from the lambda’s already-typed parameters and stays unprefixed; marking those would churn every such call site in the corpus for no defect. Full-stdlib footprint: two files, both package-level sync.OnceValue initializers (internal/sysinfo’s CPUName, internal/syscall/windows’s SupportUnixSocket/SupportTCPInitialRTONoSYNRetransmissions). (Guarded by GenericResultLambdaInfer — the nint/any/panic-terminated/two-result shapes, a concrete multi-result instantiation, and the parameter-position negative control, output-compared vs Go.)

A returned FUNC LITERAL is typeless in C#

Every arm above asks the same question — does this return expression carry a natural C# type? — and each was written against the Go-side shapes seen so far: an untyped nil, a bare constant, an untyped const wrapper. A Go function literal is a fourth shape, and it is typeless for a reason none of those tests notice: it is fully typed in Go, and it renders as a bare C# lambda, which has no natural type at all.

A function’s own returns need no help — a declared C# result type target-types them, so a method whose body sits in a frame simply returns the literal (context’s afterFuncContext.AfterFunc, which returns a Func<bool> literal from a defer-holding body). The one site that does need help is a literal returned from inside another literal. lambdaConstReturnCastType already casts a bare integer literal returned inside a lambda for exactly this reason (CS8917). Its sibling lambdaFuncLitReturnCastType names the declared result type of a returned func literal, under the same gates — inside a lambda conversion only (a named function’s returned literal is target-typed by its declared C# return type), and only when the declared result is a NAMED func type, whose emitted delegate name is what a cast needs:

mergeCancel := func(ctx, cancelCtx Context) (Context, CancelFunc) {
    
    return ctx, func() { stop(); cancel(Canceled) }
}
(context.Context, Action) mergeCancel(context.Context ctx, context.Context cancelCtx) {
    
    return (ctx, (Action)(() => {
        stopʗ1();
        cancelʗ3(context.Canceled);
    }));
}

(mergeCancel is itself only ever called, so it takes the local-function emission above — still a lambda conversion, which is what the gate tests.) Without the cast the tuple has no natural type, so neither does the enclosing conversion — CS8917 on the declaration and CS8130 at every deconstruction of its result. Naming the type is also the more faithful rendering: Go’s declared result there is CancelFunc, a methodless func type, which renders inline as its base delegate Action. An UNNAMED func() bool result would need the synthesized Func<…>/Action spelling and has no corpus site today, so it is deliberately left.

Publicization decides WHAT a type’s modifier is; the test-bridge arm only decides WHERE

visitTypeSpec writes a [GoType] declaration’s access modifier from one of two sources, and they answer different questions. packagePublicizedTypes answers what the modifier must be — an unexported type reached by an exported field, var, or callable signature has to be public or C# rejects the referrer (CS0050/CS0051/CS0052). testInlineTypeAccess answers where it is written: a white-box bridge type carries its modifier inline rather than through package_info.cs’s <TypeAccessibility> section, because its metadata anchor can be a different test class.

Asking the inline arm FIRST made it answer both — from the name alone — so a publicized bridge type stayed internal. context’s internal test file declares type testingT interface{…} and the exported func XTestParentFinishesChild(t testingT) that x_test.go calls; the publicization pre-pass records testingT (it runs over the test-augmented package, so the exported *types.Func arm fires), but the emission ignored it: internal partial interface testingT under a public method, CS0051 ×4. Publicization now outranks, and the inline arm supplies the DEFAULT:

[GoType] public partial interface testingT {   // was: internal

This is why signatureReferencesUnexportedProductionType (which downgrades an exported test-file function whose signature names an unexported PRODUCTION type) is correctly restricted to production types: a test-declared type is meant to be handled by publicization, and now is.

A white-box PRODUCTION↔PRODUCTION pointer pair is already implemented — do not record it again

Under the whitebox-reference test model the internal bridge is the SAME Go package as production, so a *prodT → prodIface cast inside an internal _test.go reads as local and records its own [assembly: GoImplement<T, Iface>(Pointer = true)]. Production, though, is a referenced assembly that already generated that adapter from its own record — and InternalsVisibleTo <assembly>.tests makes even an unexported adapter class reachable. The duplicate record makes go2cs-gen emit a SECOND adapter under a test anchor, and that copy resolves its forwarding members in the TEST class’s scope: context’s contains(pc.children, cc) converts *cancelCtx/*timerCtx to canceler for a map key, and the duplicate bound Done to the unrelated afterFuncContext.Done extension (CS1929) while emitting cancel with an empty body — a silently degraded override, not merely a build error.

The fix suppresses only the RECORD, which is what makes it small: resolveAdapterNameMarkers resolves a pair that reached no record to the unqualified name it would have had, and that name is production’s own adapter, so the cast site repoints with no other change.

!contains((~pc).children, new global::go.context_package.cancelCtxжcanceler(cc))   // production's, not a copy

It is gated on production ACTUALLY carrying the pair — its package_info.cs is loaded by convertTestVariant into importedPointerImplements — never assumed: a pair only the test converts still needs its local record. Reaching that set also required canonicalRecordIfaceName to strip a leading global::, which names no package and never appears in a parsed record; the deliberate non-collapse it documents is untouched, since a genuinely foreign pair still keys as net.http_package.ΔHandler against the record’s http_package.ΔHandler. This is the POINTER twin of the value arm’s whiteboxProductionTarget carve-out — note the pointer target arrives as a *types.Pointer, so the shared whiteboxProductionTarget flag (computed from the unwrapped VALUE form) is structurally false there and the check must unwrap and ask directly.

A GoImplement record is gated on the method set actually satisfying the interface

Every [assembly: GoImplement<T, Iface>] record makes the ImplementGenerator emit implementation glue whose members forward to T’s like-named methods — so a record whose Go method set does NOT satisfy the interface generates a forwarder to a method that does not exist. The corpus case: net/http’s err = http2GoAwayError{LastStreamID: …, ErrCode: cc.goAway.ErrCode, …} — the keyed composite’s sparse-array ident context leaks the error-typed LHS onto each FIELD value, and the ErrCode field’s value recorded GoImplement<http2ErrCode, error> even though http2ErrCode has only String()/stringToken() (its generated Error() => this.Error() was CS1929). convertToInterfaceType now folds a types.Implements check over the recorded form’s method set (T for a value record, *T for a ж<T> record) into recordableBase, which gates both the record and the matching adapter-wrapping emissions. A conversion the Go checker admitted always passes the check, so the gate can only drop pairs a caller composed from mismatched types; a type-param-carrying target skips the check (types.Implements is undefined for uninstantiated generics, and the open-generic conversion emission must stay). The full-stdlib A/B for this change is exactly one removed line — the false http2ErrCode record. (Guarded by the NEGATIVE KeyedLiteralIfaceAssign behavioral test: a keyed literal assigned to an error variable whose field-value type has String() but no Error() — a reintroduced record fails the compile phase.)

A struct embedding a FOREIGN pointer (net/http’s http2timeTimer struct { *time.Timer }, net/http/internal’s FlushAfterChunkWriter struct { *bufio.Writer }, bufio.ReadWriter { *Reader; *Writer }) promotes the embed’s pointer-receiver methods, but those land as ж-extensions visible to the consuming assembly only through METADATA — direct-ж primaries (Reset(this ж<Timer> …)) and the public RecvGenerator twins of [GoRecv] methods (Write(this ж<Writer> …)). The ImplementGenerator’s syntax-tree hop scan saw none of them, so the value-form partial deref’d to the value (this.Timer.Value.Reset(d) — CS1929, the extension receiver strands) and the FOREIGN-struct pointer adapter fell back to the struct itself (m_box.Value.Write(p) — CS1061, ReadWriter declares nothing). Both arms now probe the embed’s type SYMBOL: the single-hop paths union the foreign element’s metadata box methods into the hop’s box-method set (this.Timer.Reset(d); m_box.Value.Writer in the local pointer arm), and the foreign-struct pointer arm routes each member still on the plain fallback through the UNIQUE pointer embed whose metadata box methods declare it (m_box.Value.Writer.Write(p); Go’s depth-one promotion ambiguity rules make the unique-embed requirement faithful). An embed package class outside extension-lookup reach (not the emitting namespace, the shared root go, or an enclosing segment) forwards through its package-class static with the box as the receiver argument, mirroring the foreign-extension arm. (Guarded by the ForeignPtrEmbedIfaceLib/ForeignPtrEmbedIfaceUser pair — a local struct embedding a foreign pointer adapted by value AND by pointer, plus a foreign two-pointer-embed struct adapted by pointer, output-compared vs Go.)

A struct whose embeds are INTERFACE fields (httputil’s dumpConn struct { io.Writer; io.Reader } adapted to net.Conn) satisfies interface members through the fields’ method sets. The pointer adapter’s embedded-interface-field arm was gated to a SINGLE field, so a multi-field struct got no forwarding at all (m_box.Read(…) — CS1061). The arm now resolves per member: each still-unbound interface member forwards through the UNIQUE embedded field whose interface declares it (m_box.Value.Reader.Read(p) / m_box.Value.Writer.Write(p)); a member declared by several fields is left unbound (Go’s promotion ambiguity rules reject it unless the struct overrides, and a struct override is already resolved earlier). The single-field behavior is unchanged (zip’s nopCloser, slogtest’s Δ-renamed Handler field). (Guarded by the IfaceFieldEmbedAdapter behavioral test — a two-interface-field struct adapted by pointer to a third interface needing members from both fields plus one declared on the struct, output-compared vs Go.)

A pointer parameter used only through its box gets no deref VALUE alias

Every named pointer parameter is emitted as its box ж<T> Ꮡp with an entry-time value alias ref var p = ref Ꮡp.Value (so a value use p.field reads through p). But a parameter that the body touches only through its box — unsafe.Pointer(p)new @unsafe.Pointer(Ꮡp), p == nilᏑp == nil, or passing p on as a *T argument → Ꮡp — never references that value alias, so the alias is a dead local that nonetheless dereferences the box at function entry. When the argument is nil this NREs even though Go never touches the pointee: syscall.writeFile(…, overlapped *Overlapped), called with a nil overlapped and using it only as unsafe.Pointer(overlapped), crashed at ref var overlapped = ref Ꮡoverlapped.Value — the failure of any converted fmt.Println whose stdout is a pipe (syscall.WritewriteFile). The converter already skips the alias for an unnamed/blank pointer param (never referenced); this extends it to a named param whose value alias is likewise unreferenced. After the body is converted, bodyReferencesIdentAsValue scans the emitted body text for the value-alias name as a standalone identifier — the address marker is a Unicode letter, so the box form Ꮡp is excluded by the preceding-letter boundary, while a genuine value use always emits the bare name and matches. The scan only ever ADDS spurious matches (a field selector x.p, a string, a comment), which keep the alias, so a live alias is never dropped (no CS0103) — it removes strictly unused locals. Because a param with no alias leaves implicitPointers empty (which otherwise triggers the signature rebuild that renames pointer params to the box Ꮡ<name> the body references), a skippedDeadPointerAlias flag forces that rebuild. Full-stdlib/behavioral blast radius is broad (72 behavioral files — many functions forward a pointer param onward as a pointer), all pure alias removals. This shares its root with the receiver case below (an eager deref alias NREs on a nil pointer); the converse live-alias nil case — invoking a pointer method/function that does dereference a nil receiver/param — still NREs at the alias, awaiting the nil-safe DerefOrNil extension. (Guarded by the DeadPointerParamAlias behavioral test — a pointer param used only in p == nil, one forwarded as a pointer, and one dereferenced (alias kept), each called with nil and non-nil, output-compared vs Go; the nil calls NRE’d before the fix.)

Keeping a dead alias is not free, so the scan excludes the one spurious match that is systematic: a C# NAMED-ARGUMENT LABEL (isNamedArgumentLabel). The converter renders a Go composite literal’s field keys as named arguments to the fieldwise constructor go2cs-gen generates, and Go’s own idiom is to name the field after the value it is initialized from — so a pointer parameter whose name matches one of the literal’s FIELDS matched the whole-word scan on the label alone. internal/concurrent’s newIndirectNode(parent *indirect) { return &indirect{node: …, parent: parent} } never dereferences parent (the C# passes the box, parent: Ꮡparent), yet parent: kept the alias alive; ref var parent = ref Ꮡparent.Value then deref’d the box at ENTRY, and the ROOT node — created as newIndirectNode(nil), a legitimately nil parent — threw inside NewHashTrieMap, taking down unique’s package initializer, net/netip’s, and every dependent (found through encoding/gob’s TestNetIP). A label is the identifier immediately followed by ONE : — never :: — whose preceding non-space character opens or continues an argument list; every other colon form the converter emits is excluded by that (a case X: arm and a goto label are preceded by a keyword or a statement boundary, an interpolated format specifier {x:F2} by a brace, and a conditional’s cond ? a : b spaces its colon). A parameter genuinely used elsewhere still matches there, so this discards the label occurrence and never the scan. Whole-stdlib A/B (two seeded reconverts on the two binaries, 302 packages): 39 files, every diff hunk the removal of one dead ref var line and nothing else, and the reconverted corpus builds 304/304 with 0 errors — the removed aliases are all genuinely unreferenced. (Guarded by the same NilPointerParamUnsafePointer behavioral test, extended with the composite-literal shape and a dereferencing positive control whose alias must survive; the exclusion’s own boundaries — ::, a case X: arm, a spaced conditional colon, an interpolated format specifier, and a label that does not mask a real use elsewhere — are pinned by the TestBodyReferencesIdentAsValueIgnoresNamedArgumentLabels converter unit test, since a behavioral program cannot put most of them in that position.)

A pointer RECEIVER compared to nil compares its box, not its deref’d value

A method with a pointer receiver — func (f *File) checkValid() error { if f == nil { … } } — is emitted as checkValid(this ж<File> Ꮡf, …) with the body alias ref var f = ref Ꮡf.Value. Go’s f == nil is a pointer comparison (nil-pointer check — and Go legitimately calls methods on nil-pointer receivers), so it must compare the box Ꮡf == nil, not the deref’d struct value f. Emitted in value form, f == nil binds the generated File.operator==(File, NilType) — which compares against default(File) and, for a promoted-embed struct (File embeds *file), dereferences that zero value’s null embed box → a NullReferenceException (the first crash of a converted fmt.Println, via os.Stdout.WritecheckValid); even for a plain struct it is the wrong answer (&box{}, a non-nil pointer to a zero struct, compares equal to default(box)true where Go gives false). The converter already forced the box form for a deref’d pointer parameter (isDerefdPointerParamIdent, driving the ==/!= operand context in convBinaryExpr); the receiver is deliberately not a “parameter” in that model (paramNames excludes the receiver), so it needs its own recognizer, isDerefdPointerReceiverIdent (object-identity match via isPointerReceiver/identResolvesToReceiver, so a local shadowing the receiver name keeps its own render). It is scoped to the comparison operands only — unlike a pointer parameter the receiver is not folded into nilSafePtrParamNames, so its deref-alias form is unchanged (only the receiver’s ==/!= operand switches to the box); convIdent renders Ꮡf for the receiver through its existing direct-ж-receiver arm. Like a deref’d pointer parameter, the box form applies to every ==/!= comparison, not just against nil: a pointer receiver can only be ==-compared to another pointer or to nil, so the box is always the correct (pointer-identity) operand — func (b *Reader) Reset(r io.Reader) { if b == r … } (bufio) becomes AreEqual(Ꮡb, r), comparing the pointer to the interface’s held pointer, where the pre-fix AreEqual(b, r) compared a struct value to the interface (never equal — a latent recursion-guard bug the fix also closes). The full-stdlib A/B is small and mechanical — 161 receiver operands across 77 files gain a box (155 nil-checks, 6 pointer-identity), every changed line adding only the box. (Guarded by the PointerReceiverNilCompare behavioral test — a plain-struct *box where &box{} must compare != nil, == nil/!= nil receiver methods, and a promoted-embed *embedder whose pre-fix value comparison NRE’d, output-compared vs Go; and by the re-baselined RingPointerMethods whose r != nil receiver walk now renders Ꮡr != nil.)

A pointer RECEIVER’s deref alias is nil-DEFERRING — the panic moves to the body, it does not vanish

Go legitimately calls methods through a nil pointer: the method RUNS, and the nil-pointer panic happens only where the body actually dereferences the pointee. The emitted entry alias ref var b = ref Ꮡb.Value; instead dereferenced at ENTRY, so every nil-tolerant method panicked before it began — whichever way its guard is spelled:

Guard shape Go pre-fix C#
INLINE — func (b *Buffer) String() string { if b == nil { return "<nil>" } … } (bytes TestNil) returns "<nil>" entry panic
DELEGATED — func (f *File) Chdir() error { if err := f.checkValid("chdir"); … }, where checkValid is what asks f == nil (os TestNilFileMethods, all fifteen *File methods) returns ErrInvalid entry panic
NONE, but a side effect first — fmt.Println(…) then f.name prints, THEN panics entry panic, nothing printed

The first row was closed in 2026-07 by folding a nil-COMPARED receiver into nilSafePtrParamNames so its alias took the nil-SAFE DerefOrNil(). That could not close the other two, and widening it to every receiver was the wrong instrument: DerefOrNil() hands back a shared throwaway default(T) slot, so an unguarded deref reads a silent zero where Go panics — acceptable only where the converted guard provably excludes the read (the nil-terminated pointer walk it exists for), never as a corpus-wide default. That widening was measured and reverted.

The instrument that works is a nil-DEFERRING accessor, DerefOrNull(), which binds Unsafe.NullRef<T>() for a nil box. A null ref is legal to HOLD and to pass on as ref T; it faults on USE. So the panic is not discarded and not moved earlier — it lands exactly where Go’s does:

public static (nint n, error err) Read(this ж<File> f, slice<byte> b) {
    ref var f = ref f.DerefOrNull();          // binds; never throws
    {
        var errΔ1 = f.checkValid(readˢ);      // through the BOX — Go's guard, reached
        if (errΔ1 != default!) { return (0, errΔ1); }
    }
    (n, var e) = f.read(b);
    return (n, f.wrapErr(readˢ, e));           // the first real deref — panics here, as Go does
}

The first field read, field write, or whole-struct copy through that ref raises NullReferenceException, which RuntimeErrorPanic.TryAsPanic already maps to Go’s own runtime error: invalid memory address or nil pointer dereference — recoverable and printed verbatim. Measured, not assumed: a synthetic struct whose field sits 200 KB past the null page still faults as a clean NullReferenceException (the JIT emits an explicit check rather than relying on the 64 KB guard page), and a converted Go struct cannot reach even that offset — Go’s inline [N]T becomes a golib array<T>, an 8-byte managed reference — so there is no null-page cliff to fall off.

Where it is emitted. The entry alias exists in two places and both take the accessor, or the fix is half-done:

Because the accessor is unconditional there is no predicate to get wrong, and isComparedDirectBoxReceiverIdent — the 2026-07 receiver-specific arm of collectNilSafePtrParams — is subsumed and deleted. A non-nil receiver is unaffected: DerefOrNull() routes it to ValueSlot, the identical real slot, which additionally subsumes the isInherentlyHeapAllocatedType.ValueSlot receiver arm above (same slot, and now the genuinely-nil case is handled too rather than silently read). The scan that remained after this fix covered pointer PARAMETERS only — and the section below is why it does not exist at all any more.

(Guarded by the NilReceiverMethods behavioral test, output-compared vs go run: a delegated checkValid-style guard that must return the error, an unconditional deref that must panic with Go’s message, a side effect that must be observed BEFORE that panic, and a non-nil receiver read through both emission shapes — each panic case run through both the direct-ж preamble and the generated ref-receiver bridge. PointerReceiverNilCompare and bytes’ TestNil continue to cover the inline guard.)

A pointer PARAMETER is nil-deferring for exactly the reason a receiver is

Go’s nil rule does not distinguish the two. Passing a nil *T to a function is as legal as calling a method through one: the body RUNS, and the nil-pointer panic happens only where the body actually dereferences the pointee. So the entry alias for a pointer PARAMETER takes the same DerefOrNull() the receiver does, unconditionally, with no admitting analysis — because the accessor is faithful whether or not the body guards.

Getting here took three arms of a body ANALYSIS, each one a real fix for a real crash and each one provably incomplete, which is the argument for retiring all of them at once:

Arm The shape it admitted Why it could not be the answer
nil-COMPARED param (collectNilSafePtrParams) for p != nil { …; p = p.next }; if p == nil { … } a body that never spells the comparison is not a body that cannot take nil
nil-ARGUMENT call site (collectNilArgPtrParams) digits(ch, 10, nil) — text/scanner’s optional out-param SAME-package call sites only; the converter sees one package at a time, so a cross-package nil argument stayed broken
RE-POINTED before first read (reassignedBeforeDerefParamName) l = l.get() — Go’s nil-receiver NORMALIZATION idiom (time.Location.lookup, whose entry fault cost seven of time’s verdicts) narrow by construction: first top-level statement only, and every use inside it non-dereferencing

The shape none of them could reach is the one that broke net. internal/concurrent’s newIndirectNode(parent *indirect[K, V]) STORES its parameter and never dereferences it, and net’s package initializer reaches it through netipunique as newIndirectNode(nil) — a perfectly ordinary nil argument, from another package, with no comparison anywhere in the callee. The eager ref var parent = ref Ꮡparent.Value; faulted at entry, inside a static constructor, so every net test failed before the first one ran (net compiled with 0 errors and scored 0/138).

Nothing analyzable distinguishes that callee from one that derefs immediately. What distinguishes them is what the BODY does, at the point it does it — which is precisely what the nil-deferring accessor defers to:

// internal/concurrent/hashtriemap.go
root: newIndirectNode[K, V](nil),        // NewHashTrieMap's initializer, reached from net's .cctor
...
func newIndirectNode[K, V comparable](parent *indirect[K, V]) *indirect[K, V] {
	return &indirect[K, V]{node: node[K, V]{isEntry: false}, parent: parent}
}
internal static ж<Δindirect<K, V>> newIndirectNode<K, V>(ж<Δindirect<K, V>> parent)
    where K : /* comparable */ new()
    where V : /* comparable */ new()
{
    ref var parent = ref parent.DerefOrNull();   // was: ref Ꮡparent.Value — NRE in net's .cctor

    return (new Δindirect<K, V>(node: new node<K, V>(isEntry: false), parent: parent));
}

What the accessor costs. For a NON-nil pointer, nothing observable: DerefOrNull() routes to ValueSlot, the identical real slot, so every read, every write-through and every re-alias behaves exactly as .Value did. For a nil pointer it binds Unsafe.NullRef<T>, which is legal to hold and faults on USE — so an unguarded deref still raises Go’s runtime error: invalid memory address or nil pointer dereference (via RuntimeErrorPanic.TryAsPanic), recoverable, at the body’s own deref point, AFTER any side effect the body performed first. The retired nil-SAFE accessor could not say that: its shared default(T) slot read a silent zero where Go panics, which is why it was only ever admissible under a proof.

One shape, everywhere a pointer is aliased. The unification also takes the pointer-reassignment re-alias in visitAssignStmt (single-assign, tuple-deconstruction, and the p = nil repoint alike): a repoint is not a dereference in Go, so re-aliasing must not fault, and the two halves of one alias must not disagree about whether nil is legal to hold. And it takes the isInherentlyHeapAllocatedType.ValueSlot arm at THIS site, which is the one place the unification goes beyond swapping an accessor. .ValueSlot remains type-selected everywhere it belongs — a box-of-pointer LOCAL, a named-result box, heap(out …), the reflection bridge’s field paths — but at an entry alias it was selected AFTER the nil-safe analysis, i.e. the old code already ranked nil-ability above pointee-kind. Keeping it ranked first under an unconditional nil policy would have inverted that order and handed 9 corpus aliases across 8 files (internal/weak’s ptr, runtime mbitmap’s header, dwarf’s fixups, …) a NEW entry-time fault on exactly the nil arguments the previous converter tolerated — measured, and the reason the arm is not selected here.

Measured footprint (seeded whole-stdlib A/B reconvert, base vs fixed, 2026-08-02): 457 files, 2,551 lines, 2,555 accessor sites — 100% a single shape, the accessor token at a = ref <box>.<accessor>; position, with zero unclassified lines and no file changing its line count:

Transition Sites
.Value.DerefOrNull() 2,079
.DerefOrNil().DerefOrNull() 413
.ValueSlot.DerefOrNull() 59

Corpus-wide the entry/re-alias census moves from Value 2,849 / ValueSlot 81 / DerefOrNil 420 / DerefOrNull 2,030 to Value 766 / ValueSlot 22 / DerefOrNil 0 / DerefOrNull 4,585 — the remaining .Value are genuine USE sites, where Go does panic. (Seven DerefOrNil() sites survive in committed *_test.cs under container/ring, go/token, index/suffixarray and testing/quick: a -stdlib reconvert does not re-emit banked test sources, so they level at each package’s next -tests run.) The behavioral corpus re-baselines in the same one shape — 71 .cs, 138 lines, 69 goldens, every added line an accessor swap. The converter loses 382 net lines: the accessor constant, three analyses, their helpers, the package-wide pre-pass and the visitor state behind them.

(Guarded by the NilPointerParamMethods behavioral test, output-compared vs go run: a nil argument through a DELEGATED guard that must return the error, a nil argument STORED and never dereferenced — the newIndirectNode shape — an unconditional deref that must panic with Go’s message, a side effect that must be observed BEFORE that panic, a nil-terminated walk, a *error parameter that legally HOLDS nil, the normalization idiom, and every non-nil argument unchanged. Neuter-proven: restoring the eager param arm diverges from Go on 42 output lines. GuardedNilPointerParamDeref, PointerParamNilWalk, NilReceiverNormalization and PointerToInterfaceParamDeref continue to cover the shapes the retired arms were built for.)

A receiver or parameter RE-POINTED before first use — the normalization idiom

Go’s other nil idiom does not test the pointer at all: it normalizes it, by re-pointing through a helper that does the testing. time’s Location.lookup is the canonical case — Time{} carries a nil *Location meaning UTC, and get maps nil to &utcLoc:

func (l *Location) lookup(sec int64) (name string, offset int, start, end int64, isDST bool) {
	l = l.get()
	if len(l.zone) == 0 {  }

No comparison predicate can see this — lookup never writes l == nil — so under the eager alias the receiver faulted before its first statement, where Go returns UTC. That one site cost seven of time’s test verdicts (TestDefaultLoc, TestSecondsToUTC, TestNanosecondsToUTC, TestParse, TestTimeGob, TestTimeIsDST, TestZoneBounds), every one of them dying at entry.

It was closed first by a dedicated predicate (reassignedBeforeDerefParamName: the FIRST top-level body statement that mentions the pointer must be an = that re-points it, with every use of it inside that statement leaving the pointee untouched — a pointer-receiver method call like l.get(), a nil comparison, or the bare pointer as an argument; a field selection, a value-receiver method, or a *p disqualified it). That predicate is gone: the unconditional nil-deferring alias covers the idiom without needing to recognize it, and covers the variants it could not admit (a normalization on the SECOND statement, or one nested in an if). Both halves now read the same way:

internal static (@string name, nint offset, int64 start, int64 end, bool isDST) lookup(this ж<ΔLocation> l, int64 sec) {
    
    ref var l = ref l.DerefOrNull();          // binds for Time{}'s nil loc; no fault at entry
    l = l.get(); l = ref l.DerefOrNull();   // the repoint is not a deref either

One subtlety the predicate era got right and this must keep: a normalizer that CAN return nil (GenericFuncCall’s renewp = escape(p); *p += 10, where escape is identity) must still panic at the *p, and it does — the re-alias binds a null ref and the dereference through it faults with Go’s message, exactly where Go’s does. (Guarded by the NilReceiverNormalization behavioral test — normalize-then-read, normalize-then-write through the re-aliased receiver with a read-back proving the real slot was addressed, the same shape on a nil-legal pointer parameter, and a control that reads BEFORE normalizing and still panics through a nil receiver exactly as Go does.)

A reinterpreted raw address ALIASES native memory instead of boxing a copy

(ж<T>)(uintptr) is the reinterpret seam: it turns a raw address back into a pointer. It used to box a copy of the pointed-at value —

public static unsafe explicit operator ж<T>(uintptr value) => new ж<T>(*(T*)value.Value);

— which silently discarded the address. That is fine only for an immediate single read. It makes three things impossible: pointer arithmetic (there is no address left to advance), observing writes that native code makes afterward, and — worst — handing the pointer back to the OS, which then operates on the address of a managed box field instead of the native block.

syscall.Environ does all three. It walks the GetEnvironmentStringsW block and frees it:

envp, e := GetEnvironmentStrings()
defer FreeEnvironmentStrings(envp)
for *envp != 0 {  end = unsafe.Add(end, size)  }

Converted, the walk scanned the GC heap and the deferred FreeEnvironmentStringsW asked Windows to free GC memory — an outright STATUS_HEAP_CORRUPTION (0xC0000374) process kill. os.Environ() alone reproduced it, so nothing that reads the environment could run.

ж<T> now has a fourth reference kind alongside the standard value, struct-field and array-element refs: a box that aliases a native address. Value/ValueSlot read that memory through Unsafe.AsRef, IsNull is address-based (address 0 is the nil pointer, matching Go’s (*T)(unsafe.Pointer(uintptr(0))) == nil), and the uintptr/void* operators round-trip the address exactly — which is what Go’s uintptr(unsafe.Pointer(p)) guarantees. unsafe.Add, unsafe.Slice and unsafe.String honor the kind.

unsafe.Add also gained an unsafe.Pointer overload. Go’s unsafe.Add is byte arithmetic and its argument is always an unsafe.Pointer, but golib models unsafe.Pointer as ж<uintptr> whose value is the address — so the generic ж<T> overload, which resolves a managed array-element reference, found none and returned a nil pointer. Stepping through a native block dereferenced address 0 on the very first step.

Known limit: unsafe.Slice/unsafe.String over a native address snapshot the memory into a managed buffer rather than aliasing it the way Go does, so writes through the result do not reach the native memory. That is sufficient for reading a block a syscall returned (the Environ shape) and is where the seam still differs from Go.

An address of MANAGED storage that outlives its statement must carry a PIN

The native-address box above is exactly right when the address IS native memory — there is nothing behind it the collector could move. It is a dangling pointer when the address points into managed storage, and the reinterpret fallback produces precisely that: where (*U)(unsafe.Pointer(p)) cannot alias p’s storage in the managed model, golib names it by address instead (PointerExtensions.Reinterpret(ж<U>)(uintptr)box), and the uintptr operator’s

fixed (void* ptr = &value.Value)
    return (uintptr)ptr;

pins for that statement and no longer. The derived pointer outlives it. Once a collection moves the storage, reads through the pointer return whatever now occupies the old address — and writes land in whatever now owns it. The second is the one that matters: it is not a wrong value, it is silent heap corruption, and the crash surfaces later, somewhere unrelated. The corpus’s clearest instance of the shape is os_windows_test.go’s createMountPoint, whose four uint16 field stores go through a []byte scratch buffer addressed as a reparse record:

byteblob := make([]byte, buflen)
buf = (*windows.MountPointReparseBuffer)(unsafe.Pointer(&byteblob[0]))
buf.SubstituteNameOffset = target.substituteName.offset   // … and three more
var byteblob = new slice<byte>(buflen);
buf = (byteblob, 0).Reinterpret<byte, windows.MountPointReparseBuffer>();
buf.Value.SubstituteNameOffset = target.substituteName.offset;

The rule: the pin’s lifetime is the DERIVED POINTER’s, not the address-taking statement’s. The fallback now asks the source box for a pinned address (ж<T>.TryPinnedReinterpret), and the derived box OWNS that pin — a PinnedBuffer.PinOnly handle held in the box’s m_pin field and released by its finalizer when the box is collected. This is the same field and the same idiom as the fixed-array syscall-buffer pin (pinnedArrayData, above); the two uses are disjoint, since a native box never takes the lazy one.

Only an array/slice-element reference can be pinned, and that is not a shortcut — it is the only reference kind whose storage is an object the runtime can be asked to hold still. A native alias has nothing managed behind it; a nil box has no storage; and the storage of a standard heap box and of a struct-field reference alike is a field of a ж<T>, which holds delegates and a nullable tuple and so is never blittable — GCHandle refuses to pin it. Those kinds keep the pre-existing address route, so the change is strictly additive: where a pin cannot be taken, behavior is what it was, never something newly wrong. That is also what keeps reflect’s prefix-downcast idiom ((*structType)(unsafe.Pointer(t)) over a *abi.Type, structurally unrepresentable and deliberately on the address route) working unchanged — a blanket “fail loudly” was never available.

The pin is cross-checked before it is trusted. It is taken on the backing store CanonicalElement names, which proves something only if the referent really lives inside that object, so the address reached through the box’s own value slot must be the same byte as the address of that backing’s element; a view whose Source is a detached copy fails the test and gets no pin.

Guarded by tests/Behavioral/ReinterpretPinLifetime, which is deterministic in both directions — it writes through the derived pointer before and after enough allocation churn to move and recycle the buffer, and reads back through both the derived pointer and the original slice. Pre-fix C# printed read: false true true / write: false false false false false false on 5 of 5 runs where Go prints all true; post-fix it matches Go on 8 of 8. Its sibling ReinterpretPointerLifetime guards the other half of the same contract — the ALIASING route, for reinterprets the managed model can represent.

What this is NOT evidence of. os’s test host was separately observed dying with an ExecutionEngineException whose crash site moved between runs, and createMountPoint was the standing suspect. A pre-fix control run of the whole os suite at af5df9e16 (golib stashed back to base, the rebuilt golib.dll verified to lack the fix) reproduced no such crash, and two complete post-fix runs bracket its agreeing count from both sides, so that attribution is retracted — see the os section of docs/phase4/BOARD-next-validation-candidates.md. The pin defect is real and deterministic on its own evidence; it simply was not shown to be that host-killer.

Not fixed by this, and a different class: a destination struct holding a managed reference where Go has an inline array (PathBuffer [1]uint16array<uint16>) still fabricates an object reference out of whatever bytes sit at that offset when the field is read. Pinning makes those bytes the real buffer’s rather than recycled memory, but a fabricated reference is a CLR type-safety break either way. That is the raw-metal-on-non-native-types fork (os.readReparseLink’s remedy is a hand-owned decode; os_windows_test.go’s createMountPoint is test code that cannot be hand-owned).

unsafe.Slice over MANAGED element storage ALIASES it

The snapshot above is the right answer for a native address and the wrong one for the far commoner shape: unsafe.Slice(&s[i], n), where the pointer addresses an element of a managed slice or array. Go’s result shares that storage, so writes through the rebuilt slice must land in the original backing — and the snapshot silently swallowed every one of them. crypto/subtle is the case that exposed it: XORBytes hands xorBytes bare pointers, which rebuilds its three slices and writes the whole result through dst, so XORBytes wrote nothing at all (its test matrix compared dst against its untouched 0xdd fill).

ж<T> answers with the window when it has real managed element storage (TryGetElementWindow): the referent is reduced through the same CanonicalElement mapping pointer equality uses — so a pointer taken through a re-sliced view addresses the same absolute element Go’s would — and the result is a slice<T> over that backing with len == cap == n, exactly Go’s shape. A heap box, a struct-field ref, or a REINTERPRETING pointer (a (*U)(unsafe.Pointer(&b[0])) over a differently-typed array) has no such storage and keeps the snapshot; a T[] view over another element type does not exist in the managed model.

That last exclusion is why crypto/subtle/xor_generic.cs is hand-owned ([module: GoManualConversion]). Its word-at-a-time loop reinterprets the byte slices as []uintptr

func words(x []byte) []uintptr {
	return unsafe.Slice((*uintptr)(unsafe.Pointer(&x[0])), uintptr(len(x))/wordSize)
}

— which the converted form can only snapshot, so for every length that is a multiple of 8 the XOR went to a detached buffer and dst stayed untouched, while other lengths landed only their trailing n % 8 bytes. The hand-owned file does the same reinterpret the managed way, MemoryMarshal.Cast<byte, ulong> over the slices’ own spans — a genuine aliasing view, so the word writes land in place. It keeps Go’s word-at-a-time behavior (and with it the performance contract crypto/cipher’s CTR and GCM modes depend on) and drops only Go’s supportsUnaligned/aligned gate, which exists for architectures whose unaligned word loads fault. (Validated by crypto/subtle’s own suite: 7/7, no disclosures, over the full 1..1024 × 8 × 8 × 8 alignment matrix.)

A reinterpret of a MANAGED pointer aliases the box — it never round-trips through the address

The section above is about a pointer whose source genuinely is an address. The mirror case is (*U)(unsafe.Pointer(p)) where p is an ordinary Go pointer *T — the shape reflect uses to view one struct as another (toRType: (*rtype)(unsafe.Pointer(t))). Both pointee types are managed, so there is no native memory anywhere in the expression, yet the emission routed through the raw-address seam anyway:

return (ж<view>)(uintptr)(new @unsafe.Pointer(h));   // was
return h.Reinterpret<view>();                        // is

The old form is not merely indirect, it is unsound. golib’s implicit operator uintptr(ж<T>) ends in fixed (void* ptr = &value.Value) return (uintptr)ptr;fixed pins only for the duration of its own statement. The address escapes it, and (ж<U>)(uintptr) then builds a native-backed box holding no reference to the source. The derived pointer therefore neither keeps its pointee alive nor survives the collector moving it. Consumed immediately it happens to work; retained, it dangles, and the read comes back silently wrong once another allocation reuses the address.

That is exactly what reflect does — canonType caches the reinterpreted rtype for process lifetime — so after enough heap churn TypeOf(x).Kind() began reporting Invalid mid-process, which inverted fmt.Sprint’s “space only between two non-strings” rule corpus-wide. See docs/phase4/FINDING-managed-box-uintptr-lifetime.md.

The golib extension Reinterpret<T, TDst>() decides by provenance first:

Source pointer Result
A nil box, or a plain null reference (both are Go’s nil pointer) ж<TDst>.NilBox — Go’s (*U)(unsafe.Pointer((*T)(nil))) == nil
Aliases a NATIVE address (m_nativeAddr — a Win32 API’s returned block) the same address; the interop contract above is untouched
Owns MANAGED storage, and the reinterpret is representable (below) a box aliasing that storage, through ж’s existing struct-field-ref kind
Anything else the pre-existing address route

The managed arm recomputes its ref from a live object reference on every access (Unsafe.As<T, TDst>(ref …ValueSlot)), so it is GC-safe and needs no pin. It composes through the field-ref and array-element reference kinds — a reinterpret of &s.f or &a[i] aliases the real storage rather than a copy — and two reinterprets of one box compare equal, as Go requires (ж equality compares the source object plus the accessor, and the accessor is a static method).

It is an extension method on ж<T>?, which is what lets a null source be tolerated at all: a zero-valued pointer field is a plain null, and an instance call on it throws where Go yields nil. That is why the emission carries both type arguments.

Why the managed arm is gated. Go’s rule for (*TDst)(unsafe.Pointer(p)) is that TDst is no larger than T and the two share an equivalent layout — but that rule cannot be inherited, because a go2cs surrogate’s C# layout is not its Go layout: a Go [2]byte is 2 bytes while array<byte> is a single reference to a backing store, a Go string is 16 bytes while @string is 8, a Go []byte is 24 while slice<byte> is 32. A valid Go reinterpret can therefore become an oversized Unsafe.As that reads past the value slot into the box’s own private fields and materializes a fabricated managed reference — a CLR type-safety break, strictly worse than the contained wrong-read the address route gives. So the alias is taken only where it is demonstrably safe: both pointees value types; the destination fits inside the source; and either neither type contains managed references, or the two are layout-compatible in the senses the converter generates (the same type, a single-field wrapper over the other — Go’s struct-embedding idiom and the generated named-type wrappers — or an identical recursive field-type sequence). Everything else falls back to the address route, so the change is additive: where it does not apply, behavior is exactly what it was.

This mirrors Go’s own rule on the other axis too: a pointer obtained through unsafe.Pointer is a real reference the collector tracks, while a uintptr is a number that does not keep its referent alive — so an arithmetic-derived source ((*U)(unsafe.Pointer(uintptr(p) + off))) keeps the address route.

What this deliberately does not cover: Go’s prefix-downcast idiom, where the runtime allocates a larger struct, hands out a pointer to its embedded header, and casts back ((*structType)(unsafe.Pointer(t)) with t a *abi.Type). In Go the larger allocation is really there; in the managed model a ж<abi.Type> holds only an abi.Type, so there is nothing behind it to downcast to. Those sites keep the address route and remain the raw-metal class — which is why the two of them that converted code actually reaches, abi.Type.StructType() and ArrayType(), are hand-owned and SYNTHESIZED instead (see abi.Type’s SPECIALIZATIONS are synthesized, not downcast).

Emission detail: the peeling is shared with the identity-reinterpret elision (pointerConversionSource — it unwraps an optional abi.NoEscape/noescape wrapper and an unsafe.Pointer(p) conversion, but never a function that merely returns unsafe.Pointer, e.g. mallocgc). Identical element types stay the identity elision described above; differing element types are the genuine reinterpret. The interception sits at the two points that emit the address route — the conversion path and the regular-call path, since (*U)(unsafe.Pointer(…)) mis-classifies as a non-conversion and reaches only the latter — deliberately not upstream with the identity elision: the re-box routes above render their own conversions correctly, and diverting them breaks named ARRAY wrappers, whose lazily-materialized backing store a storage reinterpret bypasses.

A pointer-to-ARRAY target is excluded at the CONVERTER, not at the golib gate. For (*[N]T)(unsafe.Pointer(p)) the interception can only ever lose. golib never takes the managed arm for it — array<U> is an 8-byte struct holding a backing-store reference, so it fails the size gate against any smaller pointee and the reference gate against any numeric one, and Reinterpret falls straight through to the address route it was meant to replace. But the address route’s text is not inert: the slice-of-pointer-cast fusion in convSliceExpr keys on a leading (ж<…> (isPointerCast) to lower (*[N]T)(ptr)[:n] into a slice<T> over a ReadOnlySpan<T> of the pointed-to memory — the only correct lowering of that idiom, since an array<T> can neither view native memory nor be punned out of a scalar’s bytes. Emitting Reinterpret defeats the match and leaves (~box).slice(…) over an array<T> whose backing reference was read out of the pointee’s data: a fabricated managed reference, i.e. an AccessViolationException that kills the process rather than the contained wrong read the address route gives. Measured end to end on internal/syscall/windows/registry.GetStringValue — the read behind time.initLocalFromTZI and mime.initMimeWindows, so essentially every Windows program that formats a local time or looks up a MIME type: same probe, Windows 10 Pro before and a hard fault after. pointerReinterpretManagedSource therefore returns nil for an array-underlying target, restoring the previous emission exactly, fused or not. (reparse_windows.path()os.Readlink, registry Get/SetStringValue ×4, os/user, and reflect.gcSlice are on that route; 17 further corpus sites were already on the address route and are unchanged.)

(Guarded by the ReinterpretPointerLifetime behavioral output test — an aliasing case, plus a lifetime case whose reinterpreted pointer is the only surviving reference across heavy allocation churn; before the fix it printed lifetime: true false false against Go’s true true true. The gate’s fallback is guarded by FixedArrayBufferPointer, whose fixed-array pin must survive, and the array-target exclusion by PointerCastSliceReinterpret, an output test over the NON-IDENTITY pointer-cast slice that PointerCastSliceRange explicitly defers to “the stdlib exercises that shape” — which is exactly how the fabrication reached the corpus untested.)

A pointer-cast slice with a LOW bound offsets the span

(*[N]T)(ptr)[lo:hi] lowers to a slice<T> over a ReadOnlySpan<T> of the pointed-to memory (see the fusion above). The span was always built from element 0 with length hi, dropping the low bound entirely — so the result held the wrong elements whenever lo was non-nil, and was right only when lo happened to be 0. Go’s expression is the elements lo..hi, so the span must start at element lo and run hi - lo:

return syscall.UTF16ToString((*[0xffff]uint16)(unsafe.Pointer(&rb.PathBuffer[0]))[n1:n2:n2])
return syscall.UTF16ToString(new slice<uint16>(new ReadOnlySpan<uint16>(
    (uint16*)(uintptr)(new @unsafe.Pointer((rb.PathBuffer[0]))) + (int)(n1), (int)(n2) - (int)(n1))));

A pointer cast binds tighter than +, so the offset lands on the typed pointer with no extra parentheses. Two live consequences before the fix: internal/syscall/windows’s (*symbolicLinkReparseBuffer).path() slices [n1:n2:n2] to skip the print name, so os.Readlink returned the reparse buffer from offset 0 instead of the substitute name; and internal/abi’s FuncType.OutSlice() slices [InCount : InCount+outCount], so it returned the in-parameters followed by the out-parameters and reflect.Type.Out(i) indexed the wrong half (reflect.gcSlice [begin:end:end] likewise read the GC bitmap from the wrong start). (Guarded by the non-zero-low arms of PointerCastSliceReinterpret — the reparse [n1:n2:n2] shape and a byte reinterpret of a wider element sliced [3:7], both wrong before and matching Go now. StdLibInternalAbi’s golden re-baselines to the corrected OutSlice; its own stdout does not depend on the offset, which is why it stayed green through the defect.)

unsafe.Pointer(p) on a pointer PARAMETER renders the box, never a deref

A pointer parameter is emitted as the box ж<T> Ꮡp plus a deref’d VALUE alias (ref var p = ref Ꮡp.Value). Taking its address through that alias — @unsafe.Pointer.FromRef(ref p) — forces the alias to be materialized, so the entry-time deref raises a nil-pointer panic for a nil argument, even though Go never touches the pointee and uintptr(unsafe.Pointer(nil)) is defined to be 0.

Nil out-pointers are idiomatic in the syscall wrappers. DuplicateHandle takes lpTargetHandle *Handle as nil together with DUPLICATE_CLOSE_SOURCE to close a handle without receiving a duplicate, and syscall.StartProcess does exactly that in a deferred call — so spawning any child process panicked there.

The address now comes from the box: new @unsafe.Pointer(Ꮡp). golib’s implicit operator uintptr(ж<T>) already yields precisely the address Go wants — 0 for a nil box, the aliased address for a native pointer (above), the pinned storage otherwise. Rendering the box also removes the bare value reference from the body, so an otherwise-unused alias is dropped as dead by the scan described under A pointer parameter used only through its box gets no deref VALUE alias and the entry deref disappears with it. Parameters whose pointee is a basic or struct type already took this form; a named-numeric pointee (*Handle) and a pointer-to-pointer (**uint16) did not.

A pointer receiver keeps the FromRef form — a this ref T receiver has no box to address. Guarded by NilPointerParamUnsafePointer (nil and non-nil arguments across all three pointee shapes, plus a case where the value alias stays genuinely live so the box rendering must not drop it).

Implicit Pointer Dereferencing

Deciding whether a selector base is already dereferenced. A field selector on a pointer-valued base auto-derefs in Go, so the converter must insert the deref ((~x).field / x.Value.field) — unless the base is itself an explicit dereference ((*p).field) or a pointer conversion whose dedicated branch appends its own .Value. That “is the base already deref’d” test was a whole-subtree scan for any StarExpr, which mistook a conversion star buried in a call argument for a dereferenced base — stringStructOf((*string)(unsafe.Pointer(p))).n (runtime arena.go): the (*string) star belongs to the argument’s conversion, the call result (ж<stringStruct>) is not deref’d, and skipping the auto-deref left .n on the box (CS1061). The test now inspects only the base’s own outermost shape (unwrapping parens; a pointer-conversion base still routes to the conversion branch), and the conversion-branch dispatch also unwraps enclosing parens, so an extra-paren conversion base — ((*specialWeakHandle)(unsafe.Pointer(…))).handle (runtime mheap.go) — reaches it (the same extra-paren blind spot the reinterpret routing had). Reads through a conversion base are faithful; a write through one hits the copy box, the documented reinterpret-seam limitation shared by the whole (ж<T>)(uintptr) family (the runtime sites are reads). The corpus was byte-identical across all behavioral projects after the change — only previously-non-compiling shapes gained emissions. (Guarded by the PointerSelectorDeref behavioral test — both shapes, read values vs Go; cleared 3 runtime CS1061, 74 → 71.)

In Go, pointer types automatically dereference; these age assignments are equivalent:

var s struct{ age int }
var ps = &s
(*ps).age = 20
ps.age = 20

This also applies to receiver methods — a value-receiver method works on the type and on a pointer to it. In practice, the converter handles implicit dereferencing of a pointer parameter by binding a ref local to the box’s value. For example:

func PrintValPtr(ptr *int) {
    fmt.Printf("Value available at *ptr = %d\n", *ptr)
    *ptr++
}

becomes:

public static void PrintValPtr(ж<nint> ptr) {
    ref var ptr = ref ptr.Value;

    fmt.Printf("Value available at *ptr = %d\n"u8, ptr);
    ptr++;
}

A pointer local that holds a ж<T> box (e.g. x := list.head, where head is a *node) dereferences on field access through the box — a read becomes (~x).field and a write x.Value.field = …. This applies to promoted fields too: when T embeds another struct, a selector naming an embedded field (x.next where next is promoted from an embedded header) must still dereference. The converter decides this by checking field membership recursively through embeds, so a promoted-field access on a pointer local is not left as a bare x.next on the box (which has no such member, CS1061). This mirrors the Go runtime’s scanstack, which walks x := state.head; … x.nobj where nobj is promoted into stackObjectBuf from an embedded header.

When the field access is the LHS of an assignment and the chain is nestedo.stack.hi = … where o is a pointer local and stack is a value-struct field — every dereference in the base must use the assignable .Value form, not ~: (~o).stack yields a value (an rvalue), so assigning to a field through it is not a variable/property (CS0131). The converter propagates the assignment context down the selector chain, emitting o.Value.stack.hi = …. This mirrors runtime/cgocall.go’s g0.stack.hi = sp + 1024 where g0 is a *g local.

The same applies to ++/-- on a field reached through a pointer local — increment/decrement reads and writes its operand, so (~mp).ncgocall++ (a field of an rvalue) is CS1059. The converter emits the assignable mp.Value.ncgocall++.

An INDEX-expression assignment target takes the same .Value write path. When the assignment LHS is an index over a field reached through a pointer local — net/http client.go’s redirect loop req.Header[k] = vv, Header a named-map wrapper field — the read form (~req).Header[k] = vv indexer-sets a wrapper-STRUCT field of an rvalue copy (CS0131; httptest’s recorder hit the same shape). The assignment threads a dedicated IndexExprContext.isAssignmentTarget flag (distinct from the general assignment context, which also rides along RHS conversions where an index READ must keep the deref form), and convIndexExpr converts its BASE in assignment context: req.Value.Header[k] = vv;. Compound assignment (m[k] += 2), ++/-- on an index operand (m[k]++ via visitIncDecStmt), and tuple-deconstruction elements ((config.Value.Certificates[0], err) = …, net/http server.cs) take the same path. The write form is emitted for EVERY boxed index-assignment base (75 stdlib files) — for reference-backed fields (plain maps, slices, golib arrays) both forms compile and write through the same backing store, so this is churn-free semantically; the named-wrapper fields are the shapes that did not compile. (Guarded by BoxedMapFieldWrite — named-map-wrapper writes, plain-map writes, compound/inc-dec writes, and an array-field element write, all through a pointer local, values output-compared vs Go.)

Dereferencing a pointer FIELD reached through a parameter — *p.field. A *p where p is a pointer parameter is emitted as the value alias p itself (the ref var p = ref Ꮡp.Value local already denotes the pointed-to value), so the converter has a parameter-deref shortcut. That shortcut must fire only when the operand is the parameter (*p, or **p): for *p.field — a deref of a pointer field reached through p (*gp.ancestors, where ancestors is a *[]ancestorInfo) — the operand p.field is a distinct lvalue that still needs its own dereference. The shortcut keyed off the root identifier (getIdentifier digs through the selector to p), so it wrongly dropped the field deref, emitting gp.ancestors (the ж<…> pointer) instead of gp.ancestors.Value. That silently fed a pointer where the pointed-to value was expected — for _, a := range *gp.ancestors ranged the box (CS8130, since a ж<slice<…>> is not enumerable as tuples), and x := *p.cnt typed a pointer as a value (CS0029). The shortcut now excludes a selector operand, so *p.field falls through to the selector-deref path and renders p.field.Value. (Guarded by the DerefPointerToField behavioral test — a for _, x := range *h.xs over a deref’d pointer-to-slice field and a *h.cnt value read, both through a pointer parameter; runtime hit this on traceback.go’s range *gp.ancestors.) An index operand rooted at a parameter (*temps[depth], math/big’s slice-of-pointers element deref) is excluded the same way.

The receiver flavor of the same shortcut (*u inside func (u *unifier) → the deref-aliased u) had the identical overreach: it keyed off the root identifier, so *u.handles[x] — a deref of a pointer-valued map element reached through the receiver (go/types unify.go’s return *u.handles[x] and *u.handles[x] = t, handles a map[*TypeParam]*Type) — dropped the element deref entirely, returning/assigning the raw ж<ΔType> (CS0266 in both directions). The receiver shortcut is now gated on the operand being the receiver ident (object identity, like every other receiver-specific render), and the non-direct operand falls through to the tail deref: u.handles[Ꮡx].ValueSlot (ValueSlot because the element’s pointee is an interface — reference-like reads and writes both persist through the real slot). (Guarded by the RecvMapElementDeref behavioral test — element deref read and write through the receiver, the write observed through the shared pointer, alongside the genuine return *r receiver copy, output-compared vs Go.)

Labeled Control Flow and Loop Variables

Go restricts a label to immediately precede the enclosing statement (e.g. a for). Equivalent behavior is produced with a placed label and a goto:

Break Label

OuterLoop:
    for i = 0; i < n; i++ {
        for j = 0; j < m; j++ {
            switch a[i][j] {
            case nil:
                state = Error
                break OuterLoop
            }
        }
    }

becomes (the label is emitted as break_OuterLoop:):

    for (i = 0; i < n; i++) {
        for (j = 0; j < m; j++) {
            switch (a[i][j].type()) {
            case nil:
                state = Error;
                goto break_OuterLoop;
            }
        }
    }
break_OuterLoop:;

Continue Label

RowLoop:
    for y, row := range rows {
        for x, data := range row {
            if data == endOfRow {
                continue RowLoop
            }
            row[x] = data + bias(x, y)
        }
    }

becomes (continue_RowLoop: placed at the end of the labeled loop’s body — so goto continue_RowLoop from the inner loop lands there and the outer loop proceeds to its next iteration; break_RowLoop: would go after the outer loop):

    foreach (var (y, row) in rows) {
        foreach (var (x, data) in row) {
            if (data == endOfRow) {
                goto continue_RowLoop;
            }
            row[x] = data + bias(x, y);
        }
continue_RowLoop:;
    }

Both the break_<label>/continue_<label> labels are emitted for a labeled for and a labeled range/foreach loop (the label target is placed regardless of loop kind; a missing one is CS0159 “no such label”). Guarded by the ForVariants behavioral test (labeled range with nested continue/break).

A user label on an empty statement emits an explicit empty statement

A Go label can attach to an empty statementkeep: as the last line of a block, a goto/break/continue target with nothing between the label and the closing brace (internal/trace gc.go’s goto keep target at the tail of a for-loop body). A C# label must precede a statement, so a bare keep: before } is CS1525/CS1002. visitLabeledStmt detects an *ast.EmptyStmt target and emits the explicit empty statement keep:; — the same shape the break_<label>:;/continue_<label>:; synthesis already uses. A label on a non-empty statement is unchanged (big: followed by its statement stays bare). Guarded by LabeledEmptyStmt (a goto to an end-of-loop-body label, a goto to an end-of-function label, and a goto to an end-of-inner-block label — values vs Go).

Reassigned or ref-bound range variable

A C# foreach iteration variable is read-only, but Go lets a range key/value variable be reassigned inside the body (it is a per-iteration copy). When the converter detects such a reassignment (=, +=, -=, ++, …) of a newly-:=-defined range variable — or a pointer-receiver method selected on the value-typed range var (q.GoString(), whose emitted [GoRecv] form takes this ref T; a foreach var cannot bind ref — CS1657, dnsmessage’s four Message.GoString loops) — it iterates a temp and declares the variable as a mutable local copy in the body, rather than binding it directly. The per-iteration var q = vᴛ1; copy preserves Go’s semantics exactly: the pointer-receiver mutates the copy, as Go’s implicit (&q) on the range copy does. A pointer-typed range var is excluded (it dereferences; no ref bind), as are value-receiver and interface-method selections. The machinery covers string AND slice/array/map ranges:

for _, r := range s {  // r is a rune
    if r >= 0x10000 {
        r -= 0x10000   // reassigns the range variable — CS1656 on a foreach var
        
    }
}
foreach (var (_, r1) in s) {
    var r = r1;       // mutable local copy
    if (r >= 65536) {
        r -= 65536;
        
    }
}

A range variable that is only read keeps binding directly to the foreach tuple (no temp, no churn). This reuses the same temp-var/innerPrefix machinery as the for k, v = range (re-assign-into-existing-vars) form. (Guarded by the RangeVarReassign behavioral test; runtime hits this in os_windows’s UTF-16 surrogate-pair encoder.)

Addressability flows from the path ROOT, not the immediate operand. Each trigger above is matched against the identifier at the root of a field/element access path (rangeVarRootIdent), because Go’s addressability propagates through every value hop: test.x.String() with a pointer-receiver String is legal in Go (it auto-takes &test.x), and C# names the same rule in its diagnostics — CS1654/CS1655 speak of “fields of” the iteration variable. Matching only the immediate operand missed the whole class, which matters far beyond one construct because this is the table-driven test idiom that dominates the standard library’s own test suites:

for _, test := range tests {
    fmt.Println(test.x.String())   // pointer receiver on a FIELD of the range var — CS1655
}
foreach (var (_, v1) in tests) {
    var test = v1;                // mutable, addressable per-iteration copy
    fmt.Println(test.x.String());
}

The same root walk covers a write through a nested path (test.x.n = 99, CS1654) and an address taken of one (&test.x). The walk stops at the first pointer hop: past a pointer the access goes through the heap box (ж<T>.Value), an independent mutable lvalue the iteration variable’s read-only-ness never reaches — so test.ptr.Bump() keeps the direct foreach binding with no copy, and (matching Go) its write is observed by the source. Indexing likewise only continues through an array, whose storage is in-place; a slice or map index reaches a separate backing store and is its own addressability root. The copy is per-iteration, matching Go 1.22+ loop-variable semantics (see the for-clause section below).

Scoping the trigger to the root this way removes work as well as adding it: the previous field-write arm never checked whether the base was a POINTER, so a write like s.Name = x through a pointer-typed range variable emitted a needless per-iteration copy. Across the 302-package converted-stdlib corpus the net effect is 54 spurious copies eliminated and none added — the addressability additions show up in test code (the table-driven idiom), not in production sources.

For-clause variables are per-iteration (Go 1.22 loop-variable semantics)

Go 1.22 gives each iteration of a for i := …; cond; post loop its own copy of the clause-declared variables, initialized from the previous iteration’s final value. A C# for-clause variable is ONE variable shared by every iteration, so a closure captured in the body would observe the shared post-mutated final value (3 3 3 instead of Go’s 0 1 2), and a stored &i would alias one shared box — compiling code that is silently wrong. When a clause-declared variable is captured by a func literal in the body or is heap-boxed, the converter rewrites the clause to drive a renamed carrier (iᴛ1) and re-declares the real variable fresh from the carrier at the top of the body; when the body can write the variable, its value is copied back to the carrier at every transfer to the post clause — the end of the body, before an unlabeled continue (a C# continue skips the end of the body), and after the continue_<label>: target (which the copy-backs deliberately follow, so a goto continue_<label> flows through them):

var fs []func() int
for i := 0; i < 3; i++ {
	fs = append(fs, func() int { return i })
}
fmt.Println(fs[0](), fs[1](), fs[2]())   // Go 1.22+: 0 1 2
for (nint i1 = 0; i1 < 3; i1++) {
    var i = i1;               // fresh per-iteration variable — each closure captures its own
    fs = append(fs, () => i);
}

A variable the body (or a closure in it) writes adds the copy-backs:

for (nint i1 = 0; i1 < 6; i1++) {
    var i = i1;
    if (i % 2 == 0) {
        i1 = i;               // an unlabeled continue copies back at its own site
        continue;
    }
    i++;
    fs = append(fs, () => i);
    i1 = i;                   // end-of-body copy-back feeds the post clause
}

A heap-boxed clause variable allocates a fresh box each pass — the same rule as the per-iteration range-variable box: a stored &i must be a distinct pointer per iteration — and always copies back (writes through the pointer are not syntactically detectable):

for (nint i1 = 0; i1 < 3; i1++) {
    ref var i = ref heap<nint>(out var i);
    i = i1;
    ps = append(ps, i);       // three DISTINCT pointers: 0 1 2
    i1 = i;
}

Loops whose clause variables are neither captured nor boxed emit exactly as before — the shared clause variable is then unobservable, so there is no churn. A read-only captured variable skips the copy-backs. Every clause reference (init/cond/post) renders the carrier, so the body keeps the variable’s Go name — nested same-name loops compose with shadow renames (iΔ1 gets carrier iΔ1ᴛ1), and a multi-variable clause transforms only the variables that need it. One legacy fallback: a heap-boxed variable that a clause func literal references keeps the old hoisted whole-loop box, since the body-scoped box would not be in scope at the clause. (Guarded by the ForLoopPerIterationVars behavioral test — read-only capture, body write + unlabeled continue, stored &i distinctness, boxed + captured closures, labeled continue with a write, nested same-name loops, a multi-variable clause, a struct-typed clause variable, and an immediately-invoked writing closure — values vs Go. EscapedLoopVarSiblingIndex, ForVariants, and RingPointerMethods re-baselined to the per-iteration shape.)

A range-over-int index is nint (golib’s range helper yields Go’s int)

Go 1.22’s for i := range n (range over an integer) produces i of type int, which go2cs maps to nint. The converter lowers it to a foreach over golib’s range helper, and — with -var (the default) — leaves the index as the idiomatic var:

size := 5
var s []int
for i := range size {
    s = append(s, i)
}
nint size = 5;
slice<nint> s = default!;
foreach (var i in range(size)) {
    s = append(s, i);
}

For var i to infer nint — matching Go’s index type — golib’s range(nint) must yield nint, not a C# int. It originally returned Enumerable.Range(0, (int)n) (element type int), so var i inferred int. That is invisible until the index feeds a generic builtin: append(s, i) with s a slice<nint> and i an int matches two builtin.append overloads with different inferred Tappend<T>(slice<T>, params Span<T>) infers T=nint (from s; the int→nint element conversion is implicit), while append<T>(ISlice, params T[]) infers T=int (from i). Neither wins the argument-by-argument betterness tie (each is better on one argument), so the call is ambiguous — CS0121. An explicit nint i always resolved (both overloads then infer T=nint, and slice<T> beats ISlice on the first argument), which is the tell that the defect was the element type the index inferred, not the converter’s var (correct and idiomatic) nor the append overload set (unambiguous for a correctly-typed nint). The root fix is therefore in golib: range(nint) yields nint. As a hand-written iterator (for (nint i = 0; i < n; i++) yield return i;) it also matches Go’s integer-range semantics for n <= 0 exactly (zero iterations), where the old Enumerable.Range threw on a negative count. Because the converter emission is unchanged, this is a pure golib change — byte-identical check-no-regression — that silently corrects the index type for every range-over-int loop in the corpus, and unblocked the maps and slices Phase-4 test suites (whose want = append(want, i) over a range(size) index hit exactly this CS0121). Guarded by the RangeIntIndexAppend behavioral test (the minimal append(s, i)-over-range(size) shape, output-compared vs Go; neutering golib’s range back to IEnumerable<int> reproduces the CS0121).

Range-over-integer covers EVERY integer type, and the iteration variable keeps the operand’s width

Go 1.22’s range-over-integer accepts any integer type, not just int, and gives the iteration variable that type. The converter recognized only types.Int and untyped-int, so every other integer kind — uintptr, int64, uint8, rune, a named integer type — fell through visitRangeStmt’s unexpected 'ast.RangeStmt' expression arm, which prints the statement as a C# comment. The loop did not fail to convert loudly; it silently vanished, and the program quietly computed a different answer.

The corpus site that proved it is internal/abi’s for range atyp.Len inside unique.buildArrayCloneSeq (unique/clone.go), where atyp.Len is a uintptr:

for range atyp.Len {
	switch etyp.Kind() {
	case abi.String:
		seq.stringOffsets = append(seq.stringOffsets, offset)
	
	}
	offset += etyp.Size()
}
foreach (var _1 in range<uintptr>((~atyp).Len)) {
    var expr1 = etyp.Kind();
    if (expr1 == abi.ΔString) {
        seq.stringOffsets = append(seq.stringOffsets, offset);
    }
    
    offset += etyp.Size();
}

The whole body had been a /* … */ block, so unique’s cloneSeq for any array-of-string type came back empty instead of carrying one offset per element. It was the only such comment in the entire converted standard library — a one-site defect precisely because non-int range operands are rare, and invisible for exactly the same reason.

Two pieces carry the fix. golib gains a generic range<T>(T n), constrained to the operator pair the loop actually uses (IComparisonOperators<T,T,bool> + IIncrementOperators<T>, with default(T) for the zero) rather than IBinaryInteger<T> — golib’s own uintptr is a hand-written struct that implements the generic-math operator interfaces but not the INumberBase hierarchy, and uintptr is exactly the type this site ranges over. The converter then names T explicitly at every non-int site (range<uintptr>(…), range<rune>(…)), because Go’s rune and C#’s int are one CLR type and an inferred call could not tell for i := range r (yields rune) from for i := range 3 (yields Go’s int); a NAMED integer type additionally has its operand cast down to the underlying width, since a converted [GoType("num:…")] struct satisfies no generic-math interface at all.

The int case is emitted byte-identically — bare range(expr) — but it needed one guard of its own. A literal operand hands the overload set a C# int, where the new generic is an identity match and range(nint) needs an implicit numeric conversion; the generic therefore wins outright and C#’s “a non-generic method is better than a generic method” tie-break never applies (measured, not reasoned — the first attempt did regress range(3) to System.Int32). golib carries a third overload, range(int n) → IEnumerable<nint>, meaning “a C# int operand is Go’s int”: an int-typed operand in emitted code can only be an untyped Go constant, since a Go int expression already renders as nint and every other width is emitted with an explicit type argument. With it, for i := range 3 stays on nint and the CS0121 above cannot come back.

Guarded by the RangeOverIntegerTypes behavioral test (blank-key uintptr, uint8/int64/uint64/rune, a named uintptr type, non-positive operands, and the unchanged int/untyped cases, output-compared vs Go — reverting the isInt widening reproduces FAIL [Target,Output]) and by GolibTests.GoStructLayoutTests (the range<T> widths and the literal-operand overload binding). Behavioral check-no-regression is byte-identical across all 570 packages apart from the new project, which is the expected shape: the corpus contained exactly one non-int range operand.

A blank scalar range variable never emits as _

A range with no iteration variable (or an explicit blank) over a scalar-yield source — a channel, an integer (Go 1.22 for range n), or a single-value yield function — needs a C# foreach iteration variable, and that variable must not be named _: in a scalar foreach position C# declares a genuine read-only variable named _ (only tuple-deconstruction _ is a discard), which shadows the discard idiom for the entire loop body. Any Go blank assignment inside the body (_ = f(x) — evaluate and discard) then resolves to that variable and becomes an illegal write to a foreach iteration variable (CS1656; first hit: encoding/binary’s BenchmarkSize, for range b.N { _ = Size(data) }). The converter emits a marked temp instead:

for range b.N {
    _ = Size(data)
}
foreach (var _1 in range((~bΔ1).N)) {
    _ = Size(dataʗ1);
}

Tuple positions are unaffected — foreach (var (_, data) in …) keeps the true C# discard. Guarded by the RangeStatements behavioral test (blank int-range and blank channel-range, each with a body blank assignment; the compile phase is the guard — the old emission is CS1656).

for range over a slice allocates NOTHING — slice<T>.GetEnumerator() returns a struct

for i, v := range s emits foreach (var (i, v) in s), and Go’s range over a slice allocates nothing at all. C# matches that only if the enumerator stays off the heap, which is entirely a question of what GetEnumerator returns: foreach binds GetEnumerator by pattern — the concrete return type, ahead of and independently of any interface — so a struct return is enumerated in place, while an interface return is a heap object per loop entry.

slice<T>.GetEnumerator() returned IEnumerator<(nint, T)> from an ITERATOR method (yield return), which is the worst of both: the compiler-generated state machine is one allocation and the inner SliceEnumerator class it drove is a second. Measured at 136 bytes per loop entry, corpus-wide — every ranged loop in every converted package, paid whether the loop body allocated or not. It is invisible in output and in timings at small scale, and unmissable in a Go test that asserts an allocation count: time.TestUnmarshalTextAllocations runs parseRFC3339, whose parseUint closure ranges its argument once per field.

The return type is now the concrete nested slice<T>.Enumerator struct (the shape List<T>.Enumerator uses, and the one golib’s own sslice<T> already had). Two contracts had to move with it:

array<T>.GetEnumerator() is the identical shape and is deliberately not changed here: Go’s range over an array value ranges a COPY, so the eager-vs-lazy capture point is a semantic question there rather than a purely mechanical one, and it wants its own measured change.

Guarded by SliceRangeAllocationTests in GolibTests, which asserts zero bytes via GC.GetAllocatedBytesForCurrentThread across 1,000 loops (whole slice, sub-window with window-relative indices, and the nil slice), plus the interface-path equivalence. It is a measured guard on purpose: restoring the interface return type still compiles and still produces correct output — it just allocates again — so only bytes can catch the regression. Neutering to the interface return reports 48 B/loop; restoring the original iterator body reports exactly 136 B/loop.

The go.golib support namespace

golib’s hand-written support types (SparseArray<T>, PinnedBuffer, TypeExtensions, HashCode, FatalError, …) live in the go.golib child namespace — deliberately NOT go.<any Go package name>. The namespace was originally go.runtime, which collides with the real runtime package: converted code imports runtime as using runtime = runtime_package; inside namespace go, and a child namespace go.runtime visible from any referenced assembly (golib is referenced by every project) wins simple-name lookup over the alias — CS0576 at every runtime.X use (surfaced by iter/internal/weak in wave 1). The same reasoning forbids go.internal, go.sync, etc.; golib is not a Go stdlib package name, so the child namespace can never collide with an import alias. Emitted code references these types via the child namespace (new golib.SparseArray<T>{…}), which resolves inside namespace go with no using directive.

The general form of this collision — a REAL parent/child package pair — is handled by Δ-renaming the import alias. A C# using alias declared inside a namespace conflicts with a same-named child namespace visible from ANY transitively referenced assembly (CS0576 at every use), and transitivity makes this common: runtime.csproj itself references runtime/internal/math|sys (namespace go.runtime.@internal), so every package importing runtime sees a go.runtime child namespace — iter and internal/weak surfaced it in wave 1 (weak, in namespace go.@internal, collides with go.@internal.runtime from internal/runtime/* instead). A pre-pass computes the package’s transitive Go import closure (exactly mirroring MSBuild’s transitive ProjectReference visibility), derives every child-namespace chain it contributes, and Δ-renames any import alias the current package’s namespace would capture: using Δruntime = runtime_package; with uses Δruntime.Goexit() — the established collision marker. The rename propagates through one lookup to the using emission, package-qualifier identifiers, and cross-package type-name prefixes; a package with no collision emits byte-identically. (The behavioral corpus sees this on io — the real Go closure contains os → io/fs, hence go.io — captured in the AnonymousInterfaces golden as Δio.)

Three properties of the rename, established empirically (2026-07-16 review of the Δmath emissions; ruled working-as-designed): (1) The trigger is per-file and, for a top-level parent package, fires only in packages emitting into namespace go. The collision key is <packageNS>.<alias>, and usings are per-file — so math (whose own closure always contains math/bits, hence go.math) renders as Δmath in exactly the namespace go importers’ math-importing files (strconv’s ftoa/atof/eisel_lemire, fmt’s scan, reflect’s value, expvar, testing’s benchmark), while every nested-namespace importer (go.compress, go.crypto, …) keeps the clean using math = math_package;: an alias declared inside the file-scoped nested namespace wins simple-name lookup before the outer go.math is consulted. That inner-scope exemption is why the clean form dominates the corpus. (2) The baseline stub is lenient only by omission. The clean alias compiles against src/core solely because the hand-owned stub csprojs omit the Go closure (core/math references just golib); in the design-target consumption — full conversion, NuGet packages, -recurse apps, all with transitive reference visibility — the clean alias is CS0576 at every use, and hoisting it to compilation-unit scope merely trades that for CS0234 (inside namespace go the child namespace shadows the alias). Output must be context-independent, so the conservative rename stands. (3) The marker cannot be swapped onto “the colliding item”. That item is the child namespace itself — the import-path-mirroring namespace of math/bits et al., baked into separately-compiled referenced assemblies — so there is nothing local to rename, and renaming the namespace would break the path-mirroring invariant corpus-wide (the mechanism covers Δruntime ×48 files, Δsync ×38, Δio ×23, Δsyscall, Δunicode, …). The MathFloatBits and GoNamespaceShadow goldens pin the Δmath form.

Foreign renamed types reference the recorded imported-type alias

A cross-package type that is renamed (or Go-aliased) inside its own package – syscall declares ΔHandle for its type-vs-method-colliding Handle – must be referenced through the recorded imported-type alias (global using syscallꓸHandle = go.syscall_package.ΔHandle): the raw qualified render (Δsyscall.Handle) names a type that does not exist (CS0426 x26, internal/poll). The substitution lives at the C#-NAME layers – getCSharpTypeName (delegate elements, parameters, results) and getScopeCheckedTypeName (named struct fields) – and deliberately NOT in getAliasQualifiedTypeName: the Go-shaped name layer also feeds promoted-embed MEMBER naming, where the substitution renamed and rescoped the generated accessors (reflect CS8799 regression on the first cut). The GoImplicitConv assembly attributes record type names under the file-local import qualifier, so the resolving using in package_info.cs declares that same qualifier (using Δsyscall = go.syscall_package;).

A pointer/box (or other composite) element*time.Location as a func result (archive/zip’s timeZone), a *syscall.Handle parameter, a slice/map element — is renamed too, but by a different route that does not need getAliasQualifiedTypeName (so the CS8799 landmine is untouched): getAliasQualifiedTypeName renders the Go-shaped *time.Location (unrenamed, per above), then the downstream convertToCSFullTypeName applies getAliasedTypeName to the FINAL string identifier — substituting time.Location → timeꓸLocation before boxing — yielding ж<timeꓸLocation>. So the alias reaches every position that flows through the C# type-name conversion (values, pointers, boxes, composite elements alike), provided importedTypeAliases is populated. That map is loaded from the imported package’s package_info.cs (the [GoTypeAlias] round-trip), so a fresh full reconvert renders the alias everywhere; a stale/partial overlay that lacks the up-to-date package_info.cs renders the raw name and mis-reports CS0426 — the failure is in the measurement tree, not the converter (internal/trace/testtrace’s trace.Time/Event/Stack and archive/zip’s *time.Location were both bank-diagnosed as converter roots, then shown by a clean reconvert to already render traceꓸTime/ж<timeꓸLocation>).

The map is now populated for the WHOLE package before any file converts. Even within a fresh reconvert, importedTypeAliases was loaded INCREMENTALLY — visitImportSpec loads a package’s aliases only when it visits an import of that package, and files convert in sorted-filename order. So a foreign renamed type reached TRANSITIVELY — through a value whose package the current FILE does not itself import — rendered its raw (nonexistent) name if that file converted before any file that DOES import the package. go/printer’s comment.go (slash := list[0].Slash, a token.Pos read through ast.Comment, importing only go/ast) sorts first, so its slash heap box emitted heap<go.token_package.Pos> instead of heap<tokenꓸPos> (= go.go.token_package.ΔPos) — CS0426, the sole such site in the stdlib. A package-level pre-pass (preloadImportedTypeAliases, run before the file-conversion loop) now loads the exported aliases of every package ANY file imports, up front. The load is deduped per imported package, so it only FRONT-LOADS what visitImportSpec did incrementally; the alias set is file-order-independent and, because it only ADDS aliases previously missing for a transitive-use file, it can only turn a currently-WRONG render right (a compiling package has no wrong-rendered renamed type) — CNR byte-identical across the behavioral corpus, and an A/B full-stdlib reconvert changes exactly one file (go/printer/comment.cs), greening go.printer alongside the append-disambiguation root above. (Guarded by the three-package TransitiveAliasPreload fixture: CrossPkgBox.Box carries a field of CrossPkgLib’s Δ-renamed Status; the test’s a_boxed.go (sorts first) reads it transitively — return &s heap-boxes s, rendering heap<CrossPkgLibꓸStatus> — while importing only CrossPkgBox, and z_main.go (sorts last) is the only file importing CrossPkgLib. Without the preload the box renders the nonexistent CrossPkgLib_package.Status (CS0426); output-compared vs Go, 4 phases green. This is the three-package shape the 2-package CrossPkg harness could not previously express — cf. the os.FileInfo alias root, still GUARD OWED above for that reason.)

The preload still covers only packages some file imports. A foreign renamed type reached ONLY through ANOTHER package’s signature — go/types renders go/ast’s FieldFilter (func(string, reflect.Value) bool) when it passes ast.NotNilFilter to ast.Fprint, and no go/types file imports reflect — had no alias loaded at all, so the synthesized delegate wrap rendered the raw name: new Func<@string, reflect.Value, bool>(ast.NotNilFilter)Value resolved inside reflect_package (CS0426) and the mismatched delegate then failed the method-group conversion (CS0123). aliasedElementTypeName (the delegate-element rename route) now loads the owning package’s exported aliases on demand when a foreign named element has no registered alias — loadImportedTypeAliases is deduped per package, so a miss costs one probe — and the resolving global using reflectꓸValue = go.reflect_package.ΔValue; rides the normal package_info emission (the consumer sees the type through its importer’s transitive assembly reference). For LOCAL modules the resolver map (importPackageDirs) is now captured over the transitive import closure rather than direct imports only, so the same on-demand load works outside GOROOT. (Guarded by SynthesizedDelegateCrossPkg: CrossPkgFuncLib.Picker func(CrossPkgLib.Status) bool + exported Hot matching it; the consumer imports only CrossPkgFuncLib and passes Hot where a Picker is expected — the wrap must render new Func<CrossPkgLibꓸStatus, bool>(CrossPkgFuncLib.Hot); output-compared vs Go, 4 phases green.)

public static Func<CrossPkgLibStatus, nint> CheckFunc = (CrossPkgLibStatus st) => st.Code * 2;
internal static (CrossPkgLibStatus, nint) gauge(CrossPkgLibStatus st) {
internal static ж<CrossPkgLibStatus> statusPtr(ж<CrossPkgLibStatus> st) {  // *Status → box of the alias

Guarded by CrossPkgUser (CheckFunc/gauge/meterBox – delegate, signature, and field positions; statusPtr/ledger – a *CrossPkgLib.Status pointer as a func parameter, result, and struct field, each boxed as ж<CrossPkgLibꓸStatus>).

A foreign package’s collision rename is derived from that package, not from the conversion run

Everything above depends on importedTypeAliases being populated, and the only source it had was the dependency’s emitted package_info.cs — an artifact that exists only once that dependency has been converted into the output root this run resolves against. So the spelling of a foreign renamed member depended on the composition of the run: a full -stdlib run converts time before archive/tar, so writer.cs correctly emitted tw.hdr.ModTime.Round(time.ΔSecond); converting archive/tar alone (go2cs -stdlib archive/tar) emitted the unrenamed time.Second, which binds the Second(this Time) extension method group — CS0019/CS1503/CS0023, it does not compile. That hit every end-user path, where the stdlib is by definition not part of the run: a standalone go2cs <dir> and -recurse alike. time is the worst case (Second/Minute/Hour/Nanosecond/UTC/Local all collide with Time’s accessors, and Location/Month/Weekday are collision-renamed types), so the flagship four-line program d := 2 * time.Second failed to compile.

Invariant: a foreign package’s collision renames are a function of that package’s own declarations, never of which packages the current run converts. foreignCollisionTypeAliases derives them from the dependency’s loaded go/types scope — an exported package-level const/var/defined-type whose name is also a method or function name of the same package, exactly performNameCollisionAnalysis’s rule — reproducing the GoTypeAlias entries the dependency’s own conversion publishes, and feeding them through the same normalization as parsed ones (applyExportedTypeAliases) so a derived target is qualified identically. The derivation runs only where the loader previously did nothing at all (no package_info.cs on disk), so a conversion that can read the real artifact is untouched — the whole-stdlib emission is byte-for-byte unchanged. This is the same discipline packageHasMethodNamed applies to the cross-package field rename above: recompute a foreign package’s collisions from its own types.Package rather than from run-accumulated state.

Three shapes are reproduced, matching what the dependency’s conversion publishes:

Dependency declares Published entry Consumer emits
const Second Duration + func (Time) Second() int ("Second", "const:ΔSecond") time.ΔSecond
type Month int + func (Time) Month() Month ("Month", "ΔMonth") timeꓸMonth
type Token any + func (*Decoder) Token() Token ("Token", "ΔToken"), ("ΔToken", "object") object

Two shapes are deliberately not derived, because publishing a wrong target is worse than publishing none: a methodless named func type (rendered inline as its base delegate, so no <pkg>_package.Δname type exists to alias — go/doc’s ast.Filter, the same skip writePackageInfoFile applies), and a defined type over a non-empty named interface, whose alias target is a visitTypeSpec-only rendering of the RHS (no instance exists in the 302-package corpus). Both keep the pre-existing emission.

A derived alias’s global using is emitted into the consumer’s package_info.cs only when an emitted reference resolved through it. A parsed alias set describes an assembly that provably declares every target; a derived set describes what go2cs would emit for that dependency’s Go source — true of any real conversion, but not of a hand-written proxy such as the baseline core/time stub, which declares no ΔLocation/ΔMonth/ΔWeekday at all (an unused global using to one is CS0426 in every behavioral test that imports time). Gating on use keeps the derived metadata’s reach to the code that actually names the renamed member — where the rename is required for the reference to bind at all — at the cost of a single-package conversion omitting the unused alias declarations a full run emits. Every emitted reference is identical either way: a single-package -stdlib archive/tar reconvert is byte-identical to the committed full-run corpus in all code, and the flagship program compiles and runs.

Two neighboring classes of run-composition dependence share the loader and the symptom but are not collision renames, so this derivation does not cover them: a dependency’s re-exported Go type aliases (closed next) and its GoImplement pairs (loadPackageImplements, still open — see the end of the next subsection).

(Guarded by foreignNameCollisions_test.go: a two-package fixture whose dep carries one of every shape — colliding const, colliding type, colliding empty-interface type, methodless func type, and a non-colliding control — asserting the derivation, its independence from run-accumulated nameCollisions state, and the end-to-end render (dep.ΔSecond, depꓸMonth) with no package_info.cs present.)

A foreign package’s re-exported type ALIAS is derived from that package too

The sibling class, and the one that hits real end-user code hardest. os declares type FileMode = fs.FileMode (likewise FileInfo, DirEntry, PathError), and a re-export takes visitTypeSpec’s using-alias arm: the converted os emits an assembly-scoped global using FileMode = go.io.fs_package.FileMode; and publishes [assembly: GoTypeAlias("FileMode", "go.io.fs_package.FileMode")]. The re-export is therefore a using alias inside os’s assembly, never a member of os_package — so a consumer converted without that artifact emits os.PathError and gets CS0426: the type name 'PathError' does not exist in the type 'os_package'. Exactly the run-composition dependence of the collision renames, one metadata class over.

foreignTypeAliases.go derives these under the same invariant — what a dependency publishes is a function of that package’s own declarations — from its go/types scope, plus its syntax for the one distinction only a declaration’s RHS carries. Two declarations take the using-alias route and are reproduced:

Dependency declares Published entry Consumer emits
type FileMode = fs.FileMode ("FileMode", "go.io.fs_package.FileMode") osꓸFileMode
type Kind = abi.Kind (and abi Δ-renames Kind) ("Kind", "go.@internal.abi_package.ΔKind") reflectliteꓸKind
type PublicKey any (a DEFINED type over the empty interface) ("PublicKey", "object") object
type Reader io.Reader (a DEFINED type over a named interface) ("Reader", "go.io_package.Reader") pkgꓸReader

Three details make the reproduction exact rather than approximate:

Deliberately not derived (a wrong target is worse than none — a missing entry leaves the reference exactly as it converts today, a wrong one names a type that does not exist): a composite or basic RHS (type Table = map[string]int, whose rendering runs the whole convertToCSFullTypeName lowering) and an alias-to-an-alias chain; an anonymous struct/interface RHS, which is lifted under a generated name only a conversion assigns (internal/fuzz’s type CorpusEntry = struct{…}CorpusEntryᴛ1); a generic target; a methodless named func type, rendered inline as its base delegate with no named type to point at (the same omission typeCollisionAliases and writePackageInfoFile make); and a target that is itself emitted as a using alias by its own package, which would need a second hop this derivation does not follow. Each declines by shape, from the dependency’s own declarations, so the decision is stable across runs.

Same use-gating as the collision renames: a derived alias’s global using reaches the consumer’s package_info.cs only once an emitted reference has resolved through it. Evidence, taken with the fix neutered and restored: a standalone os.FileMode/os.FileInfo/os.PathError + fs.WalkDir program converted with go2cs <dir> against an output root holding no converted stdlib failed with the CS0426 above and now compiles and runs with output byte-identical to go run .; single-package -stdlib crypto/ecdh reconverts ecdh.cs byte-identically to the committed full-run corpus where before it emitted the nonexistent crypto.PublicKey; and a whole-stdlib reconvert is byte-for-byte unchanged, the derivation running only where the loader previously did nothing at all.

Still open — the GoImplement pairs (loadPackageImplements), and honestly so rather than pending: they are recorded at CONVERSION time from the cast and witness sites a dependency’s own bodies contain, so which adapter classes its assembly actually carries is a product of its emission, not of its declarations. There is nothing sound to compute from go/types: an over-approximation (every exported type × every exported interface) would name adapters that do not exist — CS0246, strictly worse than the present behavior, where the consumer records and emits its own local adapter (io_SectionReaderжReader instead of the provider’s io.SectionReaderжReader), which compiles and behaves identically and only duplicates the class. The class retires with the runtime interface shells rather than with a derivation: once a concrete-to-interface conversion goes through a runtime-constructed shell instead of a compile-time adapter, there is no per-pair record left to be missing.

(Guarded by foreignTypeAliases_test.go: a three-package fixture — a consumer, the dep whose re-exports are under test, and the other it re-exports from — carrying one declaration of every published shape and every declined one, asserting the derivation, its independence from run-accumulated state, and the end-to-end render with no package_info.cs present.)

A DOT-IMPORTED renamed type is spelled through the same alias as the qualified reference

The two subsections above are about the alias metadata being derived; this one is about it being used. Having the right alias minted is not the same as reaching it, and one reference path did not.

A dot import (. "go/types") makes a foreign type’s reference a bare *ast.Ident — there is no selector for the qualified-name resolver to rewrite — yet the type may still be collision-renamed inside its own package. The type-driven positions were always fine: a declaration, a parameter, a conversion and a field all resolve from types.Type through getCSharpTypeName/getScopeCheckedTypeName, both of which consult foreignAliasedTypeName. That is why var mu Mutex through a dot import has worked since DotImportRenamedPackage. The two AST-ident type positions did not: a type-assertion target and a composite-literal type render through convIdent’s isType arm, which returned the bare sanitized Go name and consulted nothing.

So internal/types/errors, whose external test file dot-imports go/types, emitted err._<Error>(ᐧ) and new Info(…) against declarations named ΔError and ΔInfogo/types renames Error for its own func (err Error) Error() string and Info for the unrelated func (b *Basic) Info() BasicInfo — while that test’s own package_test_info.cs had already minted global using typesꓸError = …ΔError; and typesꓸInfo, and left both unused. CS0246 ×2.

Invariant: one Go type has one C# spelling, whatever the source called it. convIdent’s isType arm now routes through foreignAliasedTypeName — the same recorded-alias lookup the qualified path takes — so Info{…} and types.Info{…} emit the identical typesꓸInfo. It is a no-op for a same-package type and for any type with no registered alias, so nothing else moves (whole-corpus CNR byte-identical).

var m = new renamedlibMarker(Name: "alpha"u8, Size: 3);          // composite literal  (was: new Marker(…))
var (got, ok) = Describe(deltaˢ, 9)._<renamedlibMarker>();      // type assertion     (was: _<Marker>(ᐧ))
var pl = new Plain(Note: "eta"u8);                                 // NOT renamed — bare, unchanged
var l = new ΔLocal(Tag: "iota"u8);                                 // same-package rename — local, no alias

The rename rule itself is performNameCollisionAnalysis’s and is worth stating exactly, because the second half is easy to miss: a package-level named element collides when some package-level FuncDecl in that package shares its name. Both a method on the type itself (Error) and a method on an unrelated type (Info) supply it; since Go forbids a type and a free function sharing a package-scope name, the collision can only ever come from a method.

(Guarded by DotImportRenamedType: a sibling library package declaring one type of each collision shape plus a non-renamed control, consumed across the package boundary through a dot import via composite literals — value and pointer — and type assertions in comma-ok, single-value and missed forms, with a same-package renamed type as the second control; output-compared vs go run. Verified to FAIL as CS0246 with the fix reverted.)

Converted programs write UTF-8 stdout — the ambient console code page never reaches the bytes

Go writes stdout as raw UTF-8, unconditionally: fmt.Println("Hello, 世界") emits the same bytes to a terminal, a pipe, or a file. .NET does not. Console.Out is constructed with Console.OutputEncoding, which on Windows defaults to the console output code page (GetConsoleOutputCP() — the OEM page, 437 on a stock US install), and to the ANSI default (1252) when the process has no console at all — the case for a program launched by the behavioral runner (CreateNoWindow = true) or by the tour’s .NET Run pane (src/tour/pipeline.go runStage, whose command.Stdout is a bytes.Buffer). Encoding a rune that code page cannot represent is not an error in .NET; the encoder substitutes ?, so the Tour of Go’s first lesson would render Hello, ?? with no diagnostic anywhere.

golib forecloses this in its [ModuleInitializer] (src/core/golib/builtin.cs), which runs before any converted code: Console.OutputEncoding = Console.InputEncoding = Encoding.UTF8. The setter also discards any already-created Console.Out, so the writer is rebuilt on the UTF-8 encoding, and .NET strips the encoding’s preamble for console writers — no BOM is prepended. A failing SetConsoleOutputCP (no console attached) is tolerated, so the redirected case is covered as fully as the interactive one. The full conversion reaches the same place by a different route and needs nothing added: real fmt writes through os.Stdoutinternal/pollsyscall.WriteFile, which hands the []byte to Win32 verbatim and is byte-transparent by construction. Only the baseline core/fmt stub — a proxy over Console.Write/Console.WriteLine — depends on the encoding above.

Guarded by the UnicodeConsoleOutput behavioral test, which prints CJK, Greek, Cyrillic, a math symbol and an astral-plane emoji, and is stdout-compared against the Go binary. The guard is differential, so it holds even under a lossy capture: the runner decodes both children’s bytes with the same encoding, and mojibake never equals ?. Neutering the golib line and running under chcp 437 fails it with stdout mismatch C# vs Go; restoring the line passes in the same console.

One divergence remains, and it is stub-only: Console.WriteLine terminates with Environment.NewLine (CRLF on Windows) where Go always writes \n, so baseline-stub output is mixed CRLF/LF — a \n inside a Printf format string stays LF. The behavioral comparison reads both children line-by-line and so normalizes this away; the full conversion does not have it at all, since WriteFile passes Go’s \n through unchanged.

Source Generators

Several Go semantics cannot be written directly in C#, so the converter emits compact, attributed partial declarations and lets a set of Roslyn source generators (src/gen/go2cs-gen/, referenced as an analyzer by every converted project) synthesize the rest at compile time. This keeps the visible converted code close to the Go original. The principal generators and attributes:

Common attributes the converter emits for the generators (and tooling) to consume: [GoType] (type bodies), [GoRecv] (receiver methods), [GoTag] (struct field tags), [GoPackage] (package info), and the test-only [GoTestMatchingConsoleOutput]. The full vocabulary — every stamp, where it lands, who reads it, and which of them are kept off the visible declaration — is classified in Extended attributes: what stays on the declaration and what moves.

A generator’s view of ACCESSIBILITY is provisional — its own output is what supplies the access modifier (2026-07-25). The converter emits a Go type as a bare [GoType] partial interface X (or partial struct X) nested in the package class and leaves the access modifier to TypeGenerator, which derives it from the Go export convention (GetScopepublic for an exported name, internal otherwise; an explicit modifier on the converter’s part wins). A C# nested type with no modifier is private, so until that generated partial exists the declaration is private — accessible from inside its own package class and inaccessible from any other class in the assembly. A generator cannot see its own output, so a semantic query that crosses package classes sees the provisional accessibility, not the real one.

That bit io’s external test package, and it is the first shape in the corpus that can hit it: io_test and io compile into ONE assembly (the recompile test-project model), as two classes. io_test.closer : io.Closer and io_test.testMultiWriter_sink : io.Writer bound their base to an IErrorTypeSymbol (CandidateReason.Inaccessible, candidate go.io_package.Closer/Interface/Private) with zero members, so every method the base contributes silently vanished from the generated interface shell and adapters — while the FINAL compilation, which does have the generated public partial interface Closer, still demanded them: four distinct CS0535 (six sites) across Δcloser<T>, ΔcloserᴛObj, PipeReaderжcloser, PipeWriterжcloser, ΔtestMultiWriter_sink<T> and bytes_BufferжtestMultiWriter_sink. The same io.Closer resolved normally as a base of io.ReadCloser inside io_package, and fmt.Stringer resolved normally from a referenced assembly (already public in metadata) — which is what made the failure look spelling-related. It is not: writing the base global::go.io_package.Closer fails identically.

Common.GetAllBaseInterfaces replaces the raw AllInterfaces walk in InterfaceDeclarationSyntaxExtensions.GetInterfaceMethods and in both of ImplementGenerator’s method-collection paths. It recovers an Inaccessible base from the error symbol’s CandidateSymbols when the candidate is an interface declared in this compilation’s assembly, and folds in that recovered base’s own transitive bases (AllInterfaces cannot traverse through an error symbol). The recovery is sound rather than a bypass: the generator is about to declare that very type public or internal, both reachable from anywhere in the assembly. A genuinely inaccessible foreign type keeps its error symbol.

The deeper alternative — having the converter emit the access modifier on its own partial, so the pre-generation source stops understating it — would remove the whole class of provisional-accessibility blind spots, but writing that modifier onto the inline [GoType] declaration re-baselines every converted .cs in the corpus and in ~490 behavioral goldens, and coarsens the Go-shaped declaration the converter works to keep readable. It was left on the table here and taken the following day in the form below. (No behavioral guard is possible for this row: one behavioral project is one Go package is one C# assembly, so two package classes never share an assembly there. The reproducer is the -tests pipeline on io, whose six CS0535 clear; gated by the full behavioral suite 490/490, the 302-package corpus build, and the bytes/strings/encoding/binary/strconv pipeline canaries all at banked counts.)

package_info.cs’s TypeAccessibility section pins each type’s accessibility IN SOURCE

Resolved (2026-07-25). The blind spot above is closed at the root, without touching a single converted .cs: package_info.cs gains a TypeAccessibility section, emitted inside the package class body (its entries are type declarations, and the types they name are nested in that class), carrying one condensed single-line partial declaration per converter-emitted [GoType] type:

[GoPackage("io")]
public static partial class io_package
{
    // <TypeAccessibility>
    internal partial struct discard {}
    internal partial struct nopCloser {}
    public partial interface Closer {}
    public partial interface Reader {}
    public partial struct LimitedReader {}
    // </TypeAccessibility>
}

C# lets a partial type carry its access modifier on any one of its parts, so the inline [GoType] partial interface Closer stays bare and Go-shaped while this part fixes the accessibility — and it is fixed in source, so a generator’s semantic query across package classes sees public/internal instead of the provisional private. The section carries its own explanatory prose in the style of the file’s other sections; a package info file written before the section existed has the prose and markers inserted on the next conversion (ensureTypeAccessibilitySection), so no migration step is needed.

Details that make it a pure relocation of the modifier rather than a change of it:

Measured, with a positive control (the -tests pipeline on io, whose CS0535 cluster is the reproducer): recovery on + section on → 0 CS0535; recovery neutered + section on → 0; recovery neutered + section off → the cluster returns. Gates: full behavioral suite 490/490 across all four phases (460 output-compared, 30 skipped) with every main .cs golden byte-identical — the churn is 490/490 package_info.cs, additions only; seeded 305-package reconvert (14/14 .cs.auto, no marked file clobbered) + overlay + full corpus build 0 errors; converter go test and GenTests green; pipeline canaries at banked counts (errors 61, encoding/csv 71, io/fs 18, bytes 81 with 7 disclosed).

Extended attributes: what stays on the declaration and what moves

The [GoType] declaration is the line a reader of converted code actually reads, so every other attribute stamped on it is machinery competing with the Go original for that reader’s attention. package_info.cs already exists to hold per-type records out of view, and the TypeAccessibility section above already moved the access modifier there. A stamp can follow it whenever its consumer reads the attribute off the TYPE rather than off a particular declaration — C# unions the attributes of every part of a partial type, so which part carries one is invisible to runtime reflection and to any generator that resolves the symbol.

That single criterion classifies the whole surface. The converter stamps nothing from the BCL — every [StructLayout], [MethodImpl] or [LibraryImport] in the corpus is in golib or in a hand-owned file — so the vocabulary is exactly this:

Stamp Lands on Consumer Verdict
[GoType], [GoType("dyn")], [GoType("num:…")], … struct / class / interface TypeGenerator’s syntax receiver keys on it; also read semantically and at runtime Must stay — it is the declaration’s identity, and the receiver has no type to resolve until it matches
[GoValueClone("f1", "f2")] struct TypeGenerator, reading field names to emit Clone() Moved
[GoLocalName("Point")] struct (lifted function-local named type) golib’s reflection bridge, GoReflect.TypeNaming Moved
[GoTag("json:\"x\"")] field golib reflection, via the DescriptionAttribute alias Must stay — field-level. A <TypeAccessibility> record is an empty {} body; C# has no way for a second part to re-declare a field and attach an attribute to it
[GoRecv] method RecvGenerator syntactically, plus runtime Must stay — same reason, one level up: a method exists on the part that defines its body
[GoArrayDims(4, 8)] parameter golib reflection — GoReflect.FuncParamDims off the delegate instance’s Method.GetParameters(), or MethodParamDims off the method table’s ParameterInfos Must stay — the sharpest case in the set: the datum is not type-keyed at all. It distinguishes two funcs that share one emitted delegate type (func([32]byte) bool and func([64]byte) bool are both Func<array<byte>, bool>), so a record keyed by type has nothing to key on, and the consumer reads the parameter’s own metadata
[GoInit] method the C# compiler — it is a using alias for ModuleInitializerAttribute Must stay — the compiler requires it on the method it initializes with
[GoPackage], [GoImplement<T,I>], [GoImplicitConv<S,T>], [GoTypeAlias] package class / assembly generators, runtime, and the converter’s own next run Already there — these are emitted into package_info.cs and never touched a mainline declaration
[GoManualConversion], [GoRequiresUnsafe] module the converter Already off — hand-written, module-scoped
[GoInterfaceShell], [GoReflectCompanion] interface / field golib Not converter-emitted — written by the generator and by hand respectively

So the movable set is [GoValueClone] and [GoLocalName], and both moved. [GoType] [GoValueClone("intbuf")] partial struct pp { reads [GoType] partial struct pp {, with the record in package_info.cs carrying the rest:

    // <TypeAccessibility>
    [GoValueClone("grid")] internal partial struct holder {}
    [GoValueClone("b")] internal partial struct inner {}
    internal partial struct row {}
    // </TypeAccessibility>

Mechanics worth knowing:

The standard-library conversion applies -tags purego

The converted standard library reproduces Go built with -tags purego, not the default amd64/arm64 build. This is a fidelity decision, not a convenience one: Go implements hot cryptographic and hashing functions in hand-written .s assembly, with the Go source carrying only a bodyless declaration (e.g. crypto/sha256/sha256block_decl.go is //go:build (386 || amd64 || s390x || ppc64le || ppc64) && !purego and declares func block(dig *digest, p []byte) with no body). A transpiler works from Go source and cannot convert assembly, so those declarations become throwing stubs — they compile (they are part of the 302-package clean-compile milestone) but cannot run, which blocks Phase-4 validation. A managed C# runtime can never execute those .s files, so “Go built with -tags purego” is a claim go2cs can actually honor, whereas “the default amd64 build” is one it fails on every asm-backed function. The purego build tag selects the portable pure-Go variants instead (sha256block_generic.go is //go:build purego || !(386 || …) and has a real body), replacing ~42 stubs across ~13 packages with convertible code. Adopting the tag drops zero compiling packages (302/302 with the tag, 302/302 without) and unblocks running.

How the tag is applied — and made visible, not magic. -stdlib applies -tags purego by default; an explicit -tags on the same command overrides it verbatim (including -tags= to clear it and reproduce the asm-backed default build). -tests gets the SAME default (resolveBuildTags): a -tests run reconverts the package’s PRODUCTION sources and recompiles them into the test assembly, so it must select the exact same source files the committed corpus was built from — that tree is Go-under-purego. Without it, a package whose asm and pure-Go variants are gated !purego/purego (crypto/subtle’s xor_amd64.go and xor_generic.go, both declaring func xorBytes) has BOTH files converted and collides (CS0111 duplicate member), and the regenerated production .cs diverges from the committed purego emission. The default is scoped to -stdlib and -tests — the whole-library corpus and its test validation — because the purego claim is about what the corpus is, so it must hold for every such invocation (direct or via any script), not depend on remembering a flag. Scripts-only (having the deploy/convert scripts pass the tag) was rejected for that reason: the canonical go2cs -stdlib invocation is used throughout the repo and docs, and a script-gated tag would make a bare -stdlib produce a different corpus. Crucially the default does not touch -recurse end-user conversions or single-file/dir conversions — there the user’s own build tags govern, and a hidden default would be least welcome. Discoverability is threefold: the -stdlib and -tags --help text both state the default, and the stdlib converter prints the effective tags at the start of every run (Applying build tags: purego (default; pass -tags to override), or … none (-tags= cleared the purego default) when overridden). The tag threads into both the go/packages loader and the converter’s own BuildConstraintEvaluator (seeding only the loader would silently re-exclude the file the tag just selected).

purego is not the only spelling of this decision — math_big_pure_go is the same one. The tag set the corpus applies is purego, math_big_pure_go, because purego is a convention the crypto and hashing packages adopted, not a language rule, and math/big predates it. math/big gates its own portable fallbacks on math_big_pure_go: arith_decl.go is //go:build !math_big_pure_go and declares eight bodyless //go:linkname///go:noescape functions — addVV, subVV, addVW, subVW, shlVU, shrVU, mulAddVWW, addMulVVW — whose bodies are arith_$GOARCH.s, while arith_decl_pure.go is //go:build math_big_pure_go and forwards each to the _g pure-Go implementation that already sits in arith.go. With only purego seeded the converter selected the declaration file, so all eight became throwing partial stubs: math/big compiled clean and could not run — every big.Int, big.Float and big.Rat arithmetic path raised on first use. (It surfaced as a single time verdict, TestTruncateRoundbig.Int.MulmulAddVWW, a good illustration of how little a visible symptom says about the size of its root.) Selecting arith_decl_pure.go also drops arith_amd64.go, whose only declaration is support_adx — read exclusively by the assembly that is not there either. The general rule this states: a package’s portable-fallback tag belongs in the default set whatever it is called; the taxonomy below classifies by whether a portable sibling exists, not by whether it happens to be spelled purego. defaultStdLibBuildTags is content-pinned by TestDefaultStdLibBuildTagsContent, which fails on an undocumented addition as well as a silent removal.

The asm-stub taxonomy. A Go declaration that a platform build binds to assembly falls into one of three buckets, each with a distinct treatment:

Known accepted divergence — crypto/elliptic P256 Inverse panics under purego

One package regresses behaviorally under purego relative to the default build, and it is recorded as a known divergence, not fixed, because matching it is fidelity: real Go panics there too under -tags purego. Upstream crypto/elliptic/nistec_p256.go is gated //go:build amd64 || arm64 without && !purego, while its dependency crypto/internal/nistec/p256_ordinv.go is (amd64 || arm64) && !purego. So under purego the ordinv fallback p256_ordinv_noasm.go — which returns errors.New("unimplemented") — is selected, and crypto/elliptic’s Inverse (reached via the deprecated invertible interface) treats any error from the nistec scalar inverse as an invariant violation and panics crypto/elliptic: nistec rejected normalized scalar. (crypto/ecdsa handles the same error correctly with a Fermat-little-theorem fallback, so ECDSA is unaffected.) Verified against real Go: the default build returns a valid inverse, and go run -tags purego panics with that exact message. This is an upstream Go inconsistency in a largely-superseded package; go2cs reproduces Go-under-purego faithfully, so the panic is expected. It is a divergence from the default (-tags=) build only, which binds the asm ordinv.

Manually-Converted Declarations

Some Go declarations cannot be faithfully auto-converted because their semantics depend on hiding a managed pointer inside an integer. The canonical family is runtime’s guintptr/puintptr/muintptr (type guintptr uintptr holding a *g the Go GC must not see): the CLR has the opposite constraint — a managed reference stored as a number is invisible to the .NET GC, so the referent can be collected or moved and the number is garbage. The managed conversion stores the ж<T> box directly and the numeric form never exists (model precedent: core/sync/atomic’s hand-rewritten Pointer<T>).

Two mechanisms deliver this, chosen by granularity:

The hand implementation (src/core/<pkg>/<file>_impl.cs, e.g. core/runtime/runtime2_impl.cs) declares the same type/extension surface the auto call sites bind: value-receiver methods as this T extensions, pointer-receiver methods as [GoRecv] this ref T, and the conversion operators call sites need. For the guintptr family that surface is: .ptr() returns the stored box, .set() stores it, .cas() is a real Interlocked.CompareExchange on the reference slot (the Go original’s atomic.Casuintptr maps to a throwing asm stub — the managed model makes it work), == 0/= 0 bind zero-comparison/nil operators, and numeric escapes are deliberate and loud: converting a non-zero integer panics (a number can never faithfully become a managed reference), and converting to a number (print/hex diagnostics) yields a stable object-identity hash — an opaque token, never an address.

One call-site emission cooperates (convCallExpr.go): a conversion to a manual type from an unsafe.Pointerguintptr(unsafe.Pointer(newg)) — unwraps the inner conversion and emits the referent-preserving ctor form new Δguintptr(newg) instead of the numeric cast chain (Δguintptr)(uintptr)new @unsafe.Pointer(newg), which would lose the referent at the (uintptr) hop.

The runtime lock/note model (core/runtime/lock_managed_impl.cs). Go’s mutex.key is a tagged atomic slot — 0 unlocked, locked (1) held, or an *m address locked heading a waiter chain through m.nextwaitm, parked on OS semaphores. The managed model hand-owns mutexContended/lock2/unlock2/notewakeup/notesleep/notetsleep_internal (via the same registry; thin wrappers and consts stay auto) and keeps the same key protocol restricted to {0, keyLocked}: the mutex is an Interlocked spinlock on the real key storage with SpinWait escalation standing in for the spin→yield→park ladder; the note is a signaled/clear latch (double-wakeup throw preserved; timeout at millisecond granularity). Deliberately not modeled, documented in place: the waiter queue (fairness), lock profiling, and the m.locks/preempt bookkeeping — getg() is a Go compiler intrinsic with no managed realization yet (a [ThreadStatic] g/m model is the future root that unlocks runtime-operational semantics; the bookkeeping returns to these bodies when it lands).

Go actually has two flavors of that protocol and picks one per GOOS: lock_sema.go (windows, darwin, plan9, aix …) parks on OS semaphores as described above, and lock_futex.go (linux, freebsd, dragonfly) uses a {0,1,2} slot and parks on a futex. Neither primitive survives conversion, so both collapse onto the identical managed answer — which is why the core above is one flat, platform-neutral file and not a copy per flavor. What genuinely differs is a single signature: notetsleep_internal is (n, ns, gp, deadline) in lock_sema.go and (n, ns) in lock_futex.go. Each flavor keeps only that declaration, four lines delegating to the shared noteSleepDeadline, in runtime/{windows,darwin}/lock_sema_impl.cs and runtime/linux/lock_futex_impl.cs. keyLocked is the managed spelling of the value Go calls locked on one flavor and mutex_locked on the other — both 1, and neither name is declared on the other flavor’s platforms.

The managed netpoller — the ten runtime_poll* contracts on .NET’s completion machinery

The fifth and deepest application of the managed-API-boundary pattern, and the one the sockaddr entry above predicted. internal/poll declares ten bodyless //go:linkname entry points into the runtime’s network poller (fd_poll_runtime.cs:18–36). The converter emits each as a bodyless partial, the PartialStubGenerator fills them with throwing stubs, and the first pollable FD.Init — which is every socket the net package creates — died in serverInit.Do(runtime_pollServerInit). os is unaffected and always was: it passes pollable: false for every file, pipe and console, so runtimeCtx == 0 short-circuits every pd call.

Why the counterparts could not simply be wired. They exist — runtime/netpoll.cs:217 carries poll_runtime_pollServerInit with its linkname comment intact, and all nine others sit beside it — but the SHALLOW wall (netpollinitstdcall4(_CreateIoCompletionPort, …)asmstdcall, a stub) is not the real one. Behind it the bodies consume runtime mutexes with lock-rank bookkeeping, pollcache over persistentalloc, the runtime timer engine, gopark with a commit callback, goready, and g-pointer CAS protocols — every one an organ of the Go scheduler. The decisive fact is that Go’s poller is only half an API: the other half (netpoll(delta), netpollBreak, netpollready) is called by the scheduler itself from findrunnable and sysmon. Under go2cs nothing would ever pump it, so a perfectly-wired conversion would initialize an IOCP and then block forever — the thread Go dedicates to draining it IS the scheduler. The ten-contract boundary is the only cut through this subsystem that does not drag a scheduler across; runtime/netpoll.cs and runtime/windows/netpoll_windows.cs stay converted and dead, with zero runtime edits.

The shape. internal/poll/windows/runtime_netpoll_impl.cs (per-GOOS folder, riding the existing $(GoTargetOS)/*.cs glob — no csproj change) supplies the ten bodies over a ManagedPollDesc: one Monitor, a sticky closing, and per mode a ready flag, a sticky expired flag, a generation counter and a System.Threading.Timer. Four decisions are worth cribbing:

Landed in stages, and only the first is banked here. S1 covers the listener lifecycle — contracts 1 (pollServerInit), 2 (pollOpen), 8 (pollUnblock), 3 (pollClose), plus a pollSetDeadline smoke — with zero data flow. The overlapped submit seam is a genuinely separate wall and is S2’s: execIO hands &o.o to WSARecv/WSASend/AcceptEx/ConnectEx, and that address is an interior field inside a reference-bearing container, which ж.cs states plainly it cannot hold still (“is left exactly as it was — a transient address”). Handing the kernel a transient interior address for an operation it retains for seconds is the pipe-EOF defect with an unbounded window, and the OVERLAPPED doubles as the operation’s kernel-side IDENTITY (CancelIoEx matches by address), so a fresh native copy per call would break cancellation outright. (Guarded by the NetListenSmoke behavioral output test, which prints kernel-derived values rather than checking for absence of a fault — an ephemeral port was assigned, two live listeners differ, and, the strongest line, the port a closed listener released can be re-bound, which is only true if pollClose released the registration before internal/poll closed the socket. Port numbers themselves are never printed, so the output is host-independent.) Full design, and the eight ruled open questions: docs/phase4/DESIGN-netpoll-managed-poller.md.

A hand-owned file can declare that it needs /unsafe

<AllowUnsafeBlocks> is converter-generated from usesUnsafeCode, and usesUnsafeCode is an emission fact: it is set while visiting Go source, so it sees only C# the converter itself wrote. A hand-owned file is by definition code the converter did not write. That left a hole with no honest way through it — a package whose only need for /unsafe was hand-written could not express it, because the .csproj is regenerated on every transpile and any value set by hand is undone by the next reconvert overlay.

[module: go.GoRequiresUnsafe] closes it. The declaration lives in the file that HAS the requirement, and the emission unions it into the property:

// core/time/time_impl.cs — `time`'s converted emission contains nothing unsafe at all
[module: go.GoRequiresUnsafe]

namespace go;

It is a union, and inert for the same reason the cross-platform union in platformEmit.go is: the property grants a capability rather than using one, so raising it moves no IL for code containing nothing unsafe. Together the two unions mean a .csproj says true when the converter’s own emission needs it on any target, or when any hand-owned file in the package declares it.

Shape follows the GoManualConversion precedent exactly — a module-scoped attribute class in golib, detected by scanning the file’s header text — and shares that marker’s scan rather than adding a second one, so it inherits the same comment lexer: a /* that is ordinary prose inside a // line comment opens no block, and a marker mentioned in a comment is not a declaration. Both marker names live in the canonical symbol table (src/core/go2cs/symbols.json), because each is one string spanning a C# attribute declaration and a Go regexp and belongs in neither.

The walk is the package’s own files plus its per-GOOS source folders, never recursive: a converted package directory can hold nested packages (internal/runtime holds syscall, atomic, …) whose own .csproj answers for them, and the discriminator is layout L3’s own — a per-GOOS folder holds no project file — so internal/syscall/windows is not read as its parent’s Windows sources. The declaration is per package rather than per platform because a .csproj is one file serving every $(GoTargetOS).

Like the hand-own marker itself, this reads the OUTPUT tree, so it inherits the same prerequisite: a reconvert must be seeded from the committed corpus, or there is nothing on disk to declare anything. That is already the standing reconvert ritual, so it adds no new rule.

Three packages declare it today, all of them at a kernel boundary: internal/runtime/syscall (the Linux Syscall6 keystone) and time flip from false, and syscall states a requirement its converted emission already happened to satisfy — inheriting a requirement by luck is how it disappears. See Every P/Invoke is source-generated for what the flag is spent on.

Hand-owns have a platform, and it is not the same question as a folder

Two different mechanisms answer two different questions, and conflating them is what produced the Linux corpus’s whole class-(b) failure surface (docs/phase4/DESIGN-multiplatform-corpus.md §12, increments 3.5 and 3.5b).

Entries now carry a goosScope, whose empty value (goosAny) means every target and is what nearly all ~120 entries use. Scoping is load-bearing in both directions:

Entry Scope Why
runtime’s mutexContended, lock2, unlock2, notewakeup, notesleep, notetsleep_internal goosAny Both flavors need hand-owning; the arity difference is the file’s problem, not the registry’s
os.(*File).readdir windows, darwin Those flavors hand OS memory to a Go struct (FILE_ID_BOTH_DIR_INFO reinterpreted; libc readdir_r(&dirent)). dir_unix.go’s is pure Go over internal/poll and converts faithfully
os.readReparseLink, syscall’s five generated wrappers windows Declared only in Go’s Windows sources; already inert elsewhere, now stated rather than re-derived

The os.(*File).readdir row is the one that cost something: unscoped, the entry deleted the perfectly convertible unix body too, so every Linux os build carried a placeholder with nothing to link against — a hand-own gap invented by the registry rather than by the Go source. Scoping it out is why Linux needs no readdir hand-own at all, which is strictly better than writing one.

What a scope deliberately does not express is a per-platform signature. The registry decides whether a declaration is hand-owned; the hand-owned file decides what it looks like. Guarded by manualConversionScope_test.go, whose fixture is the lock_sema/lock_futex pair at their real 4-vs-2 arity, plus a typo guard — a scope naming an unknown GOOS matches nothing, which would silently turn a hand-own off everywhere and is otherwise unreportable, since “not hand-owned” is a legitimate answer for every other declaration.

crypto/subtle’s word-at-a-time XOR (core/crypto/subtle/xor_generic.cs, whole-file). xorBytes XORs a machine WORD at a time by reinterpreting its three byte slices as []uintptr (unsafe.Slice((*uintptr)(unsafe.Pointer(&x[0])), len(x)/wordSize)). A uintptr[] view over a byte[] does not exist in the managed model — golib’s slice<T> is a window on a real T[] — so the converted words() could only SNAPSHOT the bytes into a detached slice<uintptr>, and the word loop XORed the snapshot and dropped it: for every length that is a multiple of 8, XORBytes wrote nothing. The whole file is hand-owned (marked [module: GoManualConversion]) and does the same reinterpret the managed way, MemoryMarshal.Cast<byte, ulong> over the slices’ own spans — a genuine aliasing view, so the word writes land in place — keeping Go’s word-at-a-time behavior and the performance contract crypto/cipher’s CTR and GCM modes depend on. Only Go’s supportsUnaligned/aligned gate is dropped (it exists for architectures whose unaligned word loads fault). Full detail: unsafe.Slice over MANAGED element storage ALIASES it. Guarded by crypto/subtle’s own suite (7/7, no disclosures, over the full 1..1024 x 8 x 8 x 8 alignment matrix).

sync/atomic.Value (core/sync/atomic/value.cs, whole-file). Go’s atomic.Value stores and loads an any atomically by reinterpreting the interface’s internal two-word (type, data) layout: (*efaceWords)(unsafe.Pointer(&v)), then atomic.LoadPointer/StorePointer/CompareAndSwapPointer on the typ and data slots, with a firstStoreInProgress sentinel guarding the first store. That layout is a Go runtime detail with no managed equivalent — an any here is a single System.Object reference (one word), and reinterpreting a managed reference as a raw address to poke type/data words simply NREs (the same managed-referent-through-unsafe.Pointer wall as the guintptr family). The first operational hit was internal/testlog’s package-level var logger atomic.Value, loaded during os.Getenv — so atomic.Value.Load() NRE’d on the zero value before any store. The whole file is hand-rewritten (marked [module: GoManualConversion]) to store the any directly in the Value.v field and use Volatile.Read/Interlocked.CompareExchange for the acquire/release ordering and CAS the literal conversion cannot provide; the nil-store and inconsistent-type panics, and CompareAndSwap’s by-value comparison (AreEqual, matching Go’s i != old), preserve the spec. Guarded by the AtomicValue behavioral test (Load-nil / Store / Swap / CompareAndSwap over typed string values, output-compared vs Go).

The internal/reflectlite mini-bridge (value_impl.cs + swapper_impl.cs, Phase-4 reflection bridge). sort.Slice/SliceStable/SliceIsSorted route through reflectlite — ValueOf(x).Len() and Swapper(x) — and the auto forms reinterpret the interface’s eface {type,data} words, so the first touch dereferenced a nil ж<abi.Type> (sort’s TestSlice, the first operational hit: unpackEfaceabi.Kind → NRE). The fix mirrors the full reflect bridge (see reflect/value_impl.cs and docs/phase4/DESIGN-reflection-bridge.md) for exactly the mini-surface sort exercises: ValueOf/unpackEface build the Value over a companion partial struct Value { object boxed } field — typ_ takes the Phase-1 synthetic abi.Type and the flag takes the Kind bits, so Kind()/IsValid() keep working from the auto value.cs unchanged — Value.Len reads the boxed value through the golib container interfaces (@string/IArray/IMap), and Swapper swaps through golib’s non-generic ISlice indexer (which applies the slice window offset, so swaps land on the shared backing store exactly like Go’s). The four declarations are skipped by the converter via the manualConversionFuncs registry ("internal/reflectlite" in go2cs/manualTypeOperations.go); the rest of reflectlite — including packEface/Interface() (used by errors.As) — stays auto and is NOT yet operational. Verified by the sort differential: TestSlice flips to pass — sort.Slice sorts through the managed Swapper, and its closing SliceIsSorted check reads length through the same ValueOf path. Go’s version counts mallocs: it pins GOMAXPROCS(1), runs f once as a warmup, then runs more times, and returns the runtime.MemStats.Mallocs delta divided (as integers) by runs. The CLR exposes no malloc counter, so the shim measures allocated bytes on the calling thread instead (GC.GetAllocatedBytesForCurrentThread() — precise, and inherently thread-scoped, which stands in for the GOMAXPROCS pinning; like Go’s, f is assumed single-threaded — allocations made by goroutines f spawns land on other threads and are not observed). The mapping is deliberately honest rather than count-approximating: zero maps exactly (0 bytes ⟺ 0 mallocs — and the stdlib tests that use AllocsPerRun overwhelmingly assert zero, e.g. sort’s TestSearchWrappersDontAlloc and the strings/bytes no-alloc guards), while a nonzero result is the average allocated bytes per run, floored at 1 so amortized sub-byte-per-run allocation can never masquerade as the exact-zero case. A converted test asserting a specific nonzero count therefore diverges as a loud failure in the differential oracle instead of silently passing — the disclosed outcome. (runs == 0 divides by zero, a runtime-error panic exactly where Go’s own integer division panics.) The capability sits in the converter’s supported list (supportedTestCapabilities, testConversion.go), so tests requiring it convert as included; guarded by TestAllocsPerRunCapabilityIsSupported (converter) and TestingRuntimeTests.AllocsPerRunMapsZeroExactlyAndReportsBytesWhenAllocating (shim).

That no COUNT is available is measured, not assumed, and a nonzero result now says which unit it is in (r56d). The value the shim returns is rendered by Go’s own "got %v allocs" format, so a byte figure was reaching the page wearing the word allocscrypto/internal/nistec’s row reads got 21964011.0, and nothing on it said that was 21 MB rather than 22 million objects. Since a disclosed divergence may never paper over a go2cs-owned defect, an invisible unit at the seam is itself the defect. The survey behind the claim (net9.0/9.0.18, x64) is recorded on the declaration: the whole public GC surface exposes byte totals only; GetAllocatedBytesForCurrentThread is exact (40.000 B/object over 1, 10, 1e3 and 1e5 allocations of a 40-byte type) yet cannot separate count from size, one byte[40000] and 1,000 40-byte objects both reading ≈40,000 B; GCAllocationTick is a byte-threshold sample, 378 events per 1,000,000 allocations (one per ≈105,820 B); GCSampledObjectAllocation — whose ObjectCountForTypeSample payload would be a count — raises zero events through an in-process EventListener in every configuration tried (High 0x200000, Low 0x2000000, both, and all keywords 0xFFFFFFFFFFFF, at Verbose and Informational), with the GC keyword’s own tick count as the live positive control; System.Runtime’s 27 EventCounters offer only alloc-rate, bytes per interval; and runtime events reach an in-process listener asynchronously — zero visible immediately after the measured loop, settling ≈117 ms later — so no event-derived figure could be returned by a synchronous call anyway. Accordingly a nonzero result records its unit once on the running test (TestExecution.NoteMeasurementUnitOnce), landing beside the assert’s own message and riding the TestEvent into results.json; the zero case is deliberately left silent, because there the two units agree exactly (0 bytes ⟺ 0 allocations) and a test that passes on the zero answer keeps its output byte-identical to before the seam existed. A true count is obtainable from go2cs’s own runtime rather than the CLR’s — golib allocates essentially every Go-semantic object, so counting there mirrors what Go’s Mallocs already is, a runtime-owned counter rather than a platform facility (proven in r56d: nistec’s P256 body allocates 241,077 golib objects per run for its 21,963,547 bytes) — but it is deliberately not taken, since a count that silently omits allocation sites is worse than an honest byte figure and an audited-total census of golib’s allocation sites is a design-with-user arc.

The strings suite exposed the two divergence classes this mapping discloses (full analysis: docs/phase4/StringsBytes-BlockerMap.md, AllocsPerRun divergence analysis): count-shape asserts (TestBuilderAllocs wants exactly 1 malloc; the shim reports bytes, so any nonzero diverges loudly — by design), and allocation-profile divergences, where the zero-shape itself is unsatisfiable because the managed model allocates where Go’s compiler doesn’t: an addressed local (var b Builder + pointer-receiver calls) heap-boxes per run where Go stack-allocates (TestBuilderGrow’s growLen=0 leg), and string(r) materializes a byte[] where Go uses a stack buffer (TestIndexRune). Neither class is a shim defect — a malloc-counting shim would fail the same asserts — and neither is faked.

A third class is neither, and must not be filed as either: ELIMINABLE inefficiency. Because zero maps exactly, a want-zero assert is faithfully representable — so when one fails, the honest reading is that the converted code genuinely allocates, and the question is what, not whether the unit is comparable. time’s TestUnmarshalTextAllocations (got 3784 allocs, want 0) measured out at 3664 bytes/run for Time.UnmarshalText, and profiling parseRFC3339 — where nearly all of it lives — attributes it to two shapes, both in shared machinery and both removable:

Shape Cost Why Status
s[a:b] on a string \| []byte-constrained value 48 B each IByteSeq<T>’s range indexer returned the interface, so the @string/slice<byte> struct result was boxed fixed — self-referential IByteSeq<TSelf, T>
[]byte(s) on the same (new slice<byte>(sΔ1)) 48 B each boxed the type-parameter value again to reach the interface fixedToSlice extension
len(s) on the same 48 B each the len<T>(IByteSeq<T>) overload took an interface parameter fixedlen<TSeq>(TSeq) where TSeq : IByteSeq
for i, c := range s over a slice<T> 136 B, fixed the range enumerator allocated once per loop, independent of length; the indexed form allocates 0 fixed — struct enumerator

Six parseUint calls at ~232 B each, the closure and delegate for parseUint itself (112 B), the fractional-second scan (~1728 B in the same shapes) and Date (~240 B) accounted for the total. None of it was CLR-necessary: Go monomorphizes the union-constrained generic and stack-allocates all of it, and nothing here was boxing that a managed model must do — it was boxing that the IByteSeq modeling and range lowering happened to do. So this row was performance work, not a disclosure candidate (a disclosure is only for asserts the CLR provably cannot satisfy — see the campaign charter §5), and it was resolved as such: the range enumerator became a struct (every converted for i, v := range s in the corpus had been paying it), and the union-constraint boxing was removed wholesale by the self-referential redesign described under Allocation-free union-constrained bodies — a parseRFC3339-shaped body over slice<byte> measures 0 B/parse where it measured 720.

The disclosed-divergence manifest (2026-07-18 ruling — implemented). These provably unsatisfiable divergences are disclosed at TEST level, extending the declaration-level “disclosed-unsupported” vocabulary: an affected package carries a hand-owned, repo-committed go2cs_test_disclosures.json beside its converted sources (never generated — reviewed like source; deliberately absent from src/core/.gitignore’s regenerated-artifact list), pinning {name, class, signature, reason} per divergent test. The -test-action compare oracle (matchTerminalStatuses, testConversion.go) reclassifies a Go=pass/C#=fail row as disclosed-divergent ONLY when the exact test name is pinned AND the captured C# failure output contains the pinned signature substring — the converted host attaches each test’s accumulated log text to its terminal event, which is what the signature matches against. The signature pin is the integrity guard: a pinned test failing with ANY other signature (e.g. an index-semantics leg regressing) is still a mismatch, a pinned test in any other status pair (including C#=infrastructure-error) is still a mismatch, and a package with no manifest compares strictly — sort and utf8 are unaffected. The validation summary discloses the reclassified rows alongside the excluded declarations (… 7 disclosed-divergent (alloc-profile), …), subtracting them from the validated count, and the nonzero C# host exit the disclosed failures cause is forgiven only when go test itself was clean and every divergence matched its pin (zero mismatches — a truncated host run surfaces as one-sided rows and stays fatal). An empty signature (which would substring-match anything) and duplicate names are load-time errors, never silent no-ops. Guards: TestDisclosedDivergenceOracle (signature match discloses / different signature still fails / no manifest strict / direction+status pairs never widen) and TestDisclosureManifestLoading (absent-file no-op; empty-signature and duplicate rejection). First users: bytes (7 alloc-profile rows) and strings (3 alloc-count-semantics + 1 alloc-profile), validating as Phase-4 packages #3 and #4. unicode/utf16 (package #5) is the first to reuse the mechanism as a general tool rather than a bytes/strings special case: its lone TestAllocationsDecode asserts Decode returns its non-escaping []rune with zero allocations — which Go reaches only through escape analysis (the test guards itself with testenv.SkipIfOptimizationOff), and which the managed runtime provably cannot, since a returned slice<rune> is always a heap allocation. It discloses one alloc-profile row (signature "Decode allocated ") while TestDecode independently proves the decoded output is correct — the disclosure covers exactly the allocation profile, nothing else.

The reflect TYPE-RELATION mirrors + Convert (Phase-3 continuation, 2026-07-26). Go’s descriptor model reaches its type relations by descriptor specialization: when Kind() == Interface the *abi.Type IS an interfaceType allocation, so implements() does Reinterpret<abi.Type, interfaceType> and walks .Methods; ptrTo builds a ptrType prototype through an eface reinterpret; FieldByName reinterprets to structType and walks .Fields. Behind a synthesized descriptor none of that layout exists — the reinterpret produces a struct whose promoted-embed box is default, and the first read throws from ж.ValueSlot (“Cannot get reference to value…”, the encoding/gob type-initializer crash). Reinterpret-specialization is therefore a class of descriptor reads that can never be honored behind the bridge; each surface severs at its semantic boundary onto the SAME golib machinery emitted asserts use: rtype.Implements / rtype.AssignableTo over GoReflect.GoImplements (mirroring the reflectlite increment-1 forms), PointerTo synthesizing the managed ж<T> pointer type (canonical via toType), rtype.FieldByName over the shared GoFields projection (top-level names; the embedded-field depth search is deferred with a named consumer — a promoted name answers Go’s not-found path), and Value.Convert over GoReflect.TryConvertTo — THE convertibility relation (the recorded R-13 remedy), severing the cvtInt → makeInt → unsafe_New stub chain (R-14; internal/fmtsort’s package-level ct() table). Value.Cap/Value.SetLen join over the golib container interfaces (gob’s decodeSlice probes Cap() < n then re-lengths the header — SetLen writes the re-windowed slice back through the aliased box), and the hand-owned rtype.Field stamps the single-hop StructField.Index — an empty Index made the auto FieldByIndex return the struct ITSELF, so gob’s encodeStruct walked every wireType field as the whole struct. Demonstrated consumers: encoding/gob’s init + Encoder/Decoder engines (a struct round-trips end-to-end), go/token’s TestSerialization (FileSet through gob, 31/31), internal/fmtsort (3/3). Registered in manualConversionFuncs["reflect"]; the banked fmtsort/go-token suites are the operational guards.

The type NAME is a descriptor read too — reflectlite’s rtype.String (2026-08-02). The same class as the specialization reads above, at its quietest. Go’s rtype.String() is t.nameOff(t.Str).Name(): Str is a name offset into the linker-built name blob, resolved by pointer arithmetic from the descriptor’s own address. A synthesized descriptor has no blob and no Str, so the mini-bridge’s String() answered "" for every type — and answered it silently, because "" is a legal name for an unnamed Go type, so nothing panicked and no read faulted; the empty string simply propagated into whatever the caller was building. context’s stringify fallback (the arm for a key type with no String() method) printed context.Background.WithValue(, c1k1) where Go prints context.Background.WithValue(context_test.key1, c1k1). That is the failure mode worth recording: a descriptor read that cannot be honored does not always throw — this one degraded to a plausible-looking empty field, and only a differential caught it.

reflect’s own rtype.String has been hand-owned over GoReflect.GoTypeName since Phase 1; this is the identical answer for the mini-bridge (internal/reflectlite/type_impl.cs, registered as manualConversionFuncs["internal/reflectlite"]["rtype.String"]), so the full bridge and the mini bridge cannot disagree about what a type is called. Array dims ride along exactly as on the reflect side — a descriptor that knows its length renders Go’s [N]T rather than []T. The managed nesting supplies the package qualifier: key1 is declared in class context_test_package, stamped [GoPackage("context_test")], so GoQualifiedName recovers context_test.key1 — including for a -tests external test package, whose Go-visible package name is the stamp’s authority rather than the class name.

Blast radius, measured rather than assumed: within reflectlite only three call sites reach String()stringify/contextName (the fix), and the assignTo and elem() panic messages, which merely become legible. No comparison logic consumes it: rtype.Name still answers "" for every type because it gates on HasName(), which reads the TFlagNamed bit that synthesizeDescriptor never sets, and the haveIdenticalType/directlyAssignable chain that would consume Name() is dead behind the hand-owned Implements/AssignableTo. errors — the mini-bridge’s other consumer — never reaches String() at all (Comparable/Kind/Implements/ AssignableTo/Elem/Set/IsNil only), and re-validates unchanged at 61/61. Demonstrated consumer: context’s TestValues, 36/38 → 37/38 (the remaining failure is TestAllocs, the measured alloc-count disclosure). rtype.Name is the recorded next gap of this shape, deliberately NOT fixed without a consumer that demonstrates it.

The method COUNT is a descriptor read too — rtype.NumMethod, the gate on json’s Unmarshaler discovery (2026-08-02). The same silent-degradation class as the NAME read above. Go’s rtype.NumMethod counts uncommon() method tables — trailing descriptor allocations the linker lays out after the abi.Type, which a synthesized descriptor never populates — so it answered 0 for every concrete type, and answered it silently, because 0 is the correct count for most types and nothing downstream faults on it. The consequence hid one hop away: encoding/json’s indirect() only ATTEMPTS its Unmarshaler/TextUnmarshaler interface assert behind v.Type().NumMethod() > 0, so no custom UnmarshalJSON/UnmarshalText was ever dispatched — every json.Unmarshal into time.Time fell through to the raw-struct path and died with json: cannot unmarshal string into Go value of type time.Time (time’s TestTimeJSON; in TestUnmarshalInvalidTimes the miss inverted the failure — {} decoded silently where Time.UnmarshalJSON rejects it). The marshal side never had the problem: newTypeEncoder gates on Implements, hand-owned since the type-relation increment.

Severed at the same semantic boundary as every read of this class: the hand-owned rtype.NumMethod (reflect/value_impl.cs) answers over golib GoReflect.GoMethodCountTypeExtensions.GoMethodSetCount, which counts over GetGoMethodSetCandidates — the same candidate source the structural probe (StructurallyImplements) and the duck-typing shell binder resolve through, so the NumMethod gate and the interface assert behind it can never disagree about a method set (a count from any other source could answer 0 for a set the assert would bind, and the gate would silently re-skip the dispatch this fixes). Candidates are deduplicated by projected Go name — one Go pointer-receiver method reaches the registry in two emitted shapes (the RecvGenerator’s ж<X> overload and the original [GoRecv] this ref X extension) — and exported-ness is judged Go’s way (first rune uppercase) on the projection, after the same leading collision-marker strip GoMethodNameMatches applies. Go’s kind split is preserved: an interface type counts ALL its methods (GetInterfaceMethodNames, instance members only — the golib static As<T> helpers stay invisible), the empty interface (object) counts 0, a concrete type counts exported only, with ж<X> seeing X’s value- AND pointer-receiver methods and a plain X only the value-receiver ones; an adapter shell answers as the Go dynamic type it stands for, mirroring the KindOf/ElementType unwrap (R10). The count is memoized per (element, pointer-ness) and cleared on assembly load with the candidate cache that feeds it.

One root, four symptom shapes — the guard (tests/Behavioral/JsonUnmarshalerDispatch) locks all four against go run: unmarshal into &t directly (the Pointer-kind gate), whole-value dispatch of a non-string JSON value (the error path), a user-declared named type with a pointer-receiver UnmarshalJSON (dispatch is not stdlib-specific), and unmarshal into struct FIELDS (the Name() != "" && CanAddr()Addr() route through the field-alias box). Demonstrated consumer: time’s TestTimeJSON and TestUnmarshalInvalidTimes. rtype.Method(i) stays auto and still reads the same absent tables — the recorded next gap of this shape: a NumMethod() > 0 gate now lets a method-ENUMERATION loop (for i := range t.NumMethod() { t.Method(i) }) get further than before, and the first consumer that walks one demonstrates it.

…and the count and the WALK are ONE increment — Type.Method(i), Value.Method(i), MethodByName (2026-08-03). The paragraph above shipped alone and was reverted: the recorded successor gap arrived one session later, on the very next all-package sweep. math/rand and math/rand/v2’s TestRegress enumerate every generator method — typ.Method(i).Name, rv.Method(i), mv.Type()’s NumIn/In, mv.Call(args) — against a golden output table, and both went from validated to panic: reflect: Method index out of range. The lesson is general and worth stating as a rule: a truthful count is a PROMISE that the table behind it can be indexed. While NumMethod answered 0 the enumeration loops were unreachable and their auto Method(i) (which reads the same absent uncommon() tables) could not be observed; making the count truthful is exactly what made them reachable. A descriptor read and the gate in front of it belong in one increment.

The whole table is now ONE list — TypeExtensions.GetGoMethodSetEntries — and NumMethod is its .Count, so a size and an order can no longer be derived separately and disagree. It is built over GetGoMethodSetCandidates, the same candidate source StructurallyImplements and AdapterBinder resolve through, then: deduplicated by projected Go name (keeping the shape a delegate can bind — a [GoRecv] this ref X receiver cannot be a Func<> parameter, and the RecvGenerator’s ж<X> overload always sits beside it), exported-only for a concrete type, and sorted ORDINALLY by Go method name, which is Go’s own method-table order (verified against go run: a promoted embedded method sorts in place, it is not appended).

A method value is an ordinary bound delegate, and that is the design’s whole economy. Go carries v.Method(i) as the receiver’s own Value plus a flagMethod bit with the index packed into the flag, then rebuilds the signature (typeSlow) and re-resolves the receiver (methodReceiver) on every use — all descriptor reads. The bridge instead BINDS the receiver into a managed delegate at Method(i) time, so the result is a Kind-Func Value and everything downstream is reuse rather than new surface: mv.Type() is the ordinary canonical Type of a delegate, NumIn/In/NumOut/Out are the existing TryFuncShape readers, and mv.Call(args) is the existing Value.Call unchanged — with the receiver already absent from the signature, which is precisely Go’s method-value contract. Type.Method(i).Func is the same delegate UNBOUND (receiver first), Go’s contract for the type side, and Value.MethodByName needs no hand-own at all: it composes the hand-owned rtype.MethodByName and Value.Method.

Binding is expression-compiled, and that is not a preference: Delegate.CreateDelegate(type, firstArgument, method) cannot close over a VALUE-type first argument (measured — ArgumentException) and Go value receivers are by-value structs, so the closed-delegate form fails for every value-receiver method, time.Time’s entire method set included. Each MethodInfo is compiled once into a Func<object?, Delegate> factory (a nested lambda — the outer takes the receiver, the inner IS the bound Go-signature delegate), so a bind costs a closure allocation, not a compile. A value-receiver method reached through *X is handed a COPY of the pointee, Go’s rule.

A this object extension method is golib plumbing, never a Go method — and filtering it is a correctness fix, not tidiness. GetGoMethodSetCandidates’ assignability safety net (there for promotion and base relationships) is satisfied by EVERY type when the receiver is object, so TypeExtensions.TryCastAsInteger(this object, out ulong) was entering every type’s method table — and entering it nondeterministically, because the candidate scan is redone whenever a late assembly load clears the caches: the same binary reported NumMethod 4 or 6 for the same type depending only on which assemblies had loaded by the first read (it reproduced under the behavioral runner’s redirected stdout and not under a console). That also means the shipped-then-reverted count was wrong in a way nothing could observe. It is filtered in the method-TABLE builder rather than in the shared candidate source, whose admission rule the duck-typing assert and the shell binder also read: a Go METHOD SET is a stricter question than “could this extension method dispatch on this value?”.

Guard: tests/Behavioral/ReflectMethodTableWalk locks the surface against go run — count/walk agreement and sorted order, a value type’s set excluding pointer-receiver methods, a bound pointer-receiver call MUTATING the receiver across calls, a value-receiver method bound through a pointer, a no-result method, MethodByName round-tripping to the same index (and both absent-name forms), the unbound Method.Func called receiver-first, promoted-embed ordering, and an interface table whose method value dispatches to the dynamic value. Demonstrated consumers: math/rand 43/43 and math/rand/v2 36/36 re-validate at their exact banked counts with TestRegress now genuinely walking (the converted bridge reports *rand.Rand NumMethod: 16 in Go’s order and Intn(1000000000) = 526058514, matching go run — where before the pair it reported 0 and the test passed VACUOUSLY, executing none of its 320 golden comparisons); time goes 146 → 148 pass of 159 as the two increment-6 JSON rows re-land. Recorded next gaps of this shape: MakeFunc, variadic Call/CallSlice, and reflectlite’s rtype.Name.

A ZERO test is a descriptor read too, and this one had degraded to a CONSTANT — Value.IsZero, Value.Grow, and the named-string Len (2026-08-03). Go’s IsZero is three reads over flat memory: an Equal function pointer compared against the shared zeroVal buffer, a TFlagRegularMemory all-bits-zero scan, and — when the value is not flagIndir — plain v.ptr == nil. A synthesized descriptor populates none of them, and the bridge never populates v.ptr or flagIndir at all, so the Array and Struct arms both fell straight to that last test and answered true for every array and every struct, whatever it held. Measured against go run before the fix: [2]uint8{1,2}, NA{1,2}, inner{N:1}, outer{P:&n} — every one of them IsZero=true in C# and false in Go. This is the ""-type-name and NumMethod-0 family again, and the worst-behaved member of it so far: true is the correct answer for the zero value of the same type, so nothing faults and nothing looks wrong.

A fourth read failed independently and had to land with it. IsZero’s String arm is v.Len() == 0, and Len answered through the golib container interfaces — a named slice is an IArray, a named map an IMap — but a type NS string wrapper implements none of them, so it fell to the 0 default. Every non-empty named string therefore reported itself both length-0 and zero. String() had always unwrapped such a wrapper; Len now does the same, gated on Kind String. That pairing is the increment-6 rule in its second form: IsZero’s String arm is a GATE on Len, so the gate and the read behind it are one increment — fixing the arms without Len would have left named strings silently zero, and fixing Len without the arms would have changed nothing.

The managed IsZero is Go’s own recursive definition with the memory shortcuts removed: a composite is zero exactly when every element (Array) or field (Struct) is, scalars test against their zero, and the nilable kinds are IsNil. That is precisely the walk the shortcuts stand in for — Go falls back to it itself when a type is not comparable and not regular-memory — so it needs no descriptor state beyond Index/Field/NumField, which the bridge already answers. Go’s blank-field skip (Name != "_") is preserved.

Value.Grow shares the root and the remedy shape: it reads a *unsafeheader.Slice off the same never-populated v.ptr, so it nil-deref’d for every callerreflect.ValueOf(&s).Elem().Grow(1) on a []byte prints 4 8 in Go and panicked here. It is now an ordinary managed reallocation (golib GoReflect.GrowSlice) written back through the aliased box exactly as SetLen does, coerced into a named slice wrapper’s slot through the single convertibility relation. Two details are load-bearing: growth within the existing capacity writes nothing at all, because Go reaches growslice only past the capacity and a spurious write would detach any other view still sharing the backing store; and the capacity landed on is unspecified in Go (its growslice rounds to a size class), so only len+n is guaranteed and the guard test asserts cap >= n, never an exact figure.

Guard: tests/Behavioral/ReflectZeroAndGrow pins all four against go run — raw and named strings, raw and named arrays (zero and non-zero, including an array OF named strings), the nil-vs-empty distinction for slices and maps, structs made non-zero through a nested named-string field alone, zeroness reached through an interface, and Grow from a nil slice / within capacity / past it / with Grow(0). Demonstrated consumer: encoding/gob, whose gobEncodeOpFor skips a field on !state.sendZero && v.IsZero() — so the encoder was omitting non-zero named-string and array fields from the wire entirely, visible as v = "", want "forty-two" on the value fields while the pointer fields of the same type passed.

Rooted and deliberately NOT landed: MapType().Hasher and Key.Equal cannot be honored at all. The remaining unique/net wall is a map descriptor whose Hasher/Key/Elem are unpopulated, so concurrent.NewHashTrieMap’s delegate construction fails on the first field it touches. Populating them looks like the same shape as the reads above and is not, because the contract differs in kind: Hasher(unsafe.Pointer, uintptr) uintptr must hash the value at an address, and the address the call site produces cannot name a managed value. Measured, three ways: two boxes holding equal @string values necessarily have different addresses (so an address-derived hash can never make unique.Make("hello") agree with itself — the package’s entire purpose); a box whose pointee contains a reference has no pinnable slot and its address moved across a forced GC; and the unsafe.Pointer handed to the delegate carries no link back to its source box by construction, its constructor taking a uintptr. The key and elem types ARE recoverable from the descriptor’s carried System.Type — but populating those alone would be actively worse than the present failure: Key.Equal is the comparability SIGNAL (a pointer-identity compare, not a value compare), so a half-populated descriptor turns a loud construction failure into a map that silently mislays every key. That is the increment-6 lesson inverted — a descriptor field whose read cannot be honored must not be populated to look truthful — and it is why this row is handed on rooted rather than half landed. The remedy is one layer down and outside this arc’s files: internal/concurrent.HashTrieMap is a managed-referent raw-metal case whose contract (a concurrent map over comparable K) the CLR answers natively while its mechanism (hash the bytes at an address) it cannot, so it wants a hand-owned _impl.cs on the sync.Mutex precedent.

Pointer order tokens — Value.Pointer()/UnsafePointer() (golib PointerOrderToken). Go programs order pointers arithmetically (cmp.Compare(a.Pointer(), b.Pointer()) — internal/fmtsort’s map-key ordering of *T/chan/unsafe.Pointer keys), so the bridge’s token must be more than stable-per-instance: equal Go pointers must token equally, and same-storage element pointers must order by element index (Go’s &a[0] < &a[1] < &a[2]). INilPointer gains the PointerOrderToken surface (a DIM default of per-instance identity): ж<T> answers nil → 0, a native alias → its real address, an array/slice-element reference → the canonical backing storage’s identity in the high bits with the ABSOLUTE element index below (the same CanonicalElement reduction pointer equality uses), a struct-field reference → source identity × field-identity token, a heap box → the referent’s identity; unsafe.Pointer (whose VALUE is a real pinned address) overrides with the address itself; channel<T> answers through its shared core’s identity, so every struct copy/boxing of one channel reports ONE token — what makes sorting channels by Pointer() self-consistent (fmtsort’s makeChans pre-sorts by the same key). Tokens are order keys consistent with pointer equality, never an identity substitute (distinct storages can collide); generated named pointer/channel wrappers keep the DIM default — a recorded fidelity residual with no consumer. The banked fmtsort suite (TestCompare/TestOrder) is the operational guard.

…except a TYPE DESCRIPTOR pointer, which orders by the type’s NAME (2026-08-10). The same Value.Pointer() carries one ordering that is visible in ordinary program output rather than only in a map of pointers: fmtsort’s reflect.Interface arm orders interface-kinded map keys by dynamic type, and it does that by comparing the two descriptors as pointers — compare(reflect.ValueOf(a.Elem().Type()), reflect.ValueOf(b.Elem().Type())) recurses into the ΔPointer arm — so this token is the printed order of fmt.Println(map[I]int{…}). Go answers with the linker’s type-section address, which is unspecified by its own admission (fmtsort’s TestInterface: “the relative ordering of types is unspecified”, asserting only that same-type keys group) and is not a function of anything the managed side can see. The identity-hash fallback above is worse than unspecified for this case — CoreCLR draws an object’s identity hash from a per-thread PRNG, so the token is fixed per build but unrelated to the type, and the printed order flips whenever an unrelated edit shifts how many hashes are drawn first. reflectPointerToken therefore routes a ж<rtype>/ж<abi.Type> through typeDescriptorOrderToken, which packs the leading IntPtr.Size bytes of the descriptor’s Go name (the one Type.String() prints) big-endian, so comparing tokens arithmetically compares the names lexically: types that print alike token alike, and types that print differently order by that printed name — stable across builds, runs and unrelated edits. Names agreeing over the whole packed prefix tie and fall through to fmtsort’s concrete-value arm (Go’s own “no good answer” -1, settled deterministically by SortStableFunc’s stability); matching Go’s layout order for three or more key types is not on offer and would not be a property Go promises. Guarded by the banked fmtsort suite (TestInterface’s grouping) and the InterfaceInheritance behavioral test’s output comparison, which is what caught the PRNG model landing tails. Full derivation: docs/phase4/DESIGN-reflection-bridge.md.

EXPORTEDNESS is a descriptor read too, and the value side had been right about it all along — StructField.PkgPath (2026-08-11). reflect.StructField.IsExported() is nothing but f.PkgPath == "", and Go fills PkgPath with the declaring package’s import path for an unexported field (type.go: if !p.Name.IsExported() { f.PkgPath = t.PkgPath.Name() }). The hand-owned rtype.Field(i) left it unset — the field’s own comment said so, on the reading that no truthful read backed it — so IsExported() answered true for every field of every converted struct. Silent, like every member of this family: "" is the correct PkgPath for the exported fields that are most fields, so nothing faulted and nothing looked wrong.

The consequence is a guard that can never fire. encoding/asn1 opens both its struct arms — parseField and makeField — with

for i := 0; i < structType.NumField(); i++ {
    if !structType.Field(i).IsExported() {
        return StructuralError{"struct contains unexported fields"}
    }
}

so Marshal(unexported{X: 5, y: 1}) returned a nil error where Go returns that structural error, and Unmarshal ran straight past the refusal into parseField(val.Field(i), …) on the unexported field, where SetInt reached mustBeAssignable and panicked (TestUnexportedStructField). Note what that panic proves: the two halves of the read-only model had degraded independently. Value.Field already stamped flagStickyRO for an unexported field — from GoReflect.GoFields, the same projection the type side walks — so CanSet()/ CanInterface() were correct and the write was correctly refused; it was only the TYPE-side descriptor that had no answer, which is why a package that probes settability got no warning while a package that simply writes got a clean Go-shaped panic. PkgPath now derives from the same projection’s Exported bit plus GoReflect.GoPackagePath (the package identity the managed nesting carries, already rtype.PkgPath’s source), so a probe of the type and a write through the value cannot disagree about a field.

Two neighbouring StructField members stay unpopulated, for two different reasons worth keeping apart. Offset is the r39d rule — a descriptor field whose read cannot be honored must not be populated to look truthful: a Go byte offset exists to be added to a data pointer, and managed storage has no such pointer. (abi.StructType does populate Offset, and correctly: its consumers — unique’s clone sequencer, internal/reflectlite — read it as layout metadata, never as an address to walk.) Anonymous is the opposite case: it IS knowable, since an embedded field arrives through golib’s promoted-embed box hop, but no measured consumer demands it and the recorded next gap of that shape is larger — go2cs-gen emits the promoted-embed backing box AFTER the declared fields, so struct{X; y; Inner; inner; Ptr} walks as X, y, Ptr, Inner, inner here where Go walks it in declaration order. Field ORDER and Anonymous want one increment together, with a consumer that demonstrates them.

Guard: tests/Behavioral/ReflectUnexportedFieldFlags, byte-identical to go run — the indexed walk’s IsExported/PkgPath/Tag, FieldByName carrying the same flags (including the blank field, which Go also reports unexported), a field-for-field assertion that the type side and the value side AGREE (v.Field(i).CanSet() == t.Field(i).IsExported()), and the consumer shape itself: a decoder that probes before writing must be able to refuse with a returned error rather than a panic. Demonstrated consumer: encoding/asn1’s TestUnexportedStructField.

A func PARAMETER is the one position an array’s LENGTH cannot be recovered from — [GoArrayDims] (2026-08-11). A Go array’s length is part of its type, and it is the one part the managed emission cannot carry: [32]byte renders as golib array<byte>, and C# has no const generic parameter to hold the 32. The bridge has always answered that by recovering the dimension from a live source instead, and the two it had covered every position that mattered — a VALUE measures itself (GoReflect.ArrayDimsOfValue), and a struct FIELD reads it off a cached zero instance of the declaring struct, because the converter emits the dimension as a field initializer (= new(32)) that the generated parameterless constructor runs.

A func parameter has neither. There is no value at a type-only position, no initializer to read, and the emitted delegate type is a bare Func<array<byte>, bool> that func([32]byte) bool and func([64]byte) bool share. So reflect.TypeOf(f).In(0) answered a dims-less array descriptor: Len() 0 and String() "[]uint8" — which does not even read as an array — and reflect.New/ reflect.Zero of it built a zero-length array. testing/quick is the consumer that shows what that costs, because its generator allocates the argument from the parameter type alone (v := reflect.New(concrete).Elem(), then for i := 0; i < v.Len(); i++): every property test over a fixed-size array ran against the EMPTY value. crypto/internal/edwards25519’s TestScalarSetCanonicalBytes indexed in[len(in)-1] and panicked with index out of range [-1] with length 0; its sibling TestScalarSetUniformBytes reported failed on input [0]uint8{}, which names the empty array outright.

The datum therefore has to live at the parameter, and it does: the converter stamps [GoArrayDims(32)] there (outermost dimension first — [2][3]int[GoArrayDims(2, 3)]), from the single generateParametersSignature all three signature builders share, so declarations, methods, func literals, func types and interface methods are all covered by one emission point.

f1 := func(in [32]byte, sc Scalar) bool {  }      // edwards25519's scalar_test.go
var f1 = ([GoArrayDims(32)] array<byte> @in, Scalar sc) => {  };

GoReflect.FuncParamDims reads it back off the delegate INSTANCEDelegate.Method.GetParameters(), which resolves to the real declaration for every shape go2cs emits (a declared func used as a method group, a non-capturing lambda, a capturing lambda’s display-class method, a natural-typed lambda, a local function) — and abi.TypeOf stamps it as descriptor cargo beside arrayDims, so reflect.Type.In(i) hands out an array type that knows its length. The cargo joins BOTH interning keys (abi.descriptorDimsKey, shared with reflect’s canonType) for the reason the array dims are already in them: func([32]byte) bool and func([64]byte) bool are distinct Go types over one managed delegate type, so interning them together would let whichever arrived first answer In(0).Len() for both.

Three boundaries are deliberate. A defined (named) array type is not stamped — its managed form is a generated wrapper, not array<T>, so dims cargo could not be consumed even if carried — while an alias for an array is, because a Go alias is its target type. Result dims are not carried at all: a multi-result Go func returns a ValueTuple, which has no per-element attribute position, and no measured consumer reads Out(i).Len(). And a delegate whose target method’s parameter list does not line up one-for-one with Invoke’s — an open instance delegate carries the receiver as an extra leading parameter, and the bridge’s own method values are expression-compiled closures with no attributes — is answered null rather than mis-indexed, which is the r39d rule in its usual form: a dims-less descriptor is a state the bridge already handles, a mis-indexed one is not.

The same datum two hops further out — a METHOD’s parameters, and a *[N]T (2026-08-14). net/rpc is the consumer that found both hops missing, and it found them the hard way. Its server allocates every reply argument from the method type alone — replyv = reflect.New(mtype.ReplyType.Elem()), where ReplyType came from mtype.In(2) — so a service method declared func (BuiltinTypes) Array(i int, reply *[1]int) error needs the 1 to survive two hops the func-value route does not have:

  1. The func type comes from the method TABLE, not from a delegate instance. reflect.Type.Method(i).Type is synthesized in value_impl.cs from GoMethodFuncType over the MethodInfo’s parameters, and nothing in that path ever holds a delegate for GoReflect.FuncParamDims to read. GoReflect.MethodParamDims(t, i) reads the same [GoArrayDims] stamps straight off those ParameterInfos, and Method(i) carries them as the descriptor’s funcParamDims. It owes no arity guard, unlike the delegate route: the delegate type is synthesized FROM that parameter list, receiver included, so the indices line up with In(i) by construction — which is Go’s own shape for a method type, receiver first.
  2. The array sits behind a POINTER. A callee that writes its result through a parameter takes a *T, so the type-only position whose length must survive is the pointee’s. The converter now stamps a parameter’s pointee dims (one hop; that is all a Go signature spells here), and a pointer descriptor’s dims pass through Elem() unshifted — there is nothing else they could describe, a pointer having no length of its own — while an array’s dims still shift, its element consuming the outer one. The stamp had to be added to visitFuncDecl’s REBUILT signature path as well, and that is where it actually fires: having a pointer parameter is itself what triggers the rebuild, so a *[N]T parameter never reaches generateParametersSignature at all.

Without either hop, reflect.New(In(2).Elem()) built a zero-length array and the callee’s first write panicked index out of range [0] with length 0 — on an rpc goroutine, which took the entire converted-test host down with it (net/rpc/jsonrpc’s TestBuiltinTypes; the same run’s other eight tests then recorded no verdict at all, which is what made the panic read as three failures instead of one).

Guard: tests/Behavioral/ReflectFuncArrayParamDims, byte-identical to go runIn(0)’s String/Kind/Len and the New/Zero lengths across a literal, a multi-parameter literal, a nested [2][3]int, a declared func used as a value and a func with no array parameter at all; the distinctness of [32]byte and [64]byte as In(0) types; the inner dimension surviving Elem(); the struct-field route still answering; and quick’s generation loop in miniature (allocate from the parameter type, fill through Index(i).Set, Call), so a zero-length synthesis shows up as the callee’s wrong answer rather than a silent pass. It carries rpc’s shape too, since 2026-08-14: Method(i).Type.In(2).Elem().Len() for a *[3]int reply, reflect.New of it, and the callee writing through the pointer — a length the descriptor does not know is not merely mis-reported there, it PANICS. Converter unit guard: TestGoArrayDimsAttribute (both shapes, including the boundaries: a pointer to a DEFINED array type, to a slice, and a double pointer are all unstamped). Corpus footprint of the 2026-08-14 half, measured by re-transpiling all 592 behavioral packages: 5 declarations in 5 files, one line each — four *[N]T parameters, plus one [4]byte VALUE parameter (DeferTypelessReturnsfirst) that had been silently unstamped all along, its function taking the rebuilt path because it heap-boxes. Nothing else moved.

The reflect.DeepEqual bridge (reflect/deepequal_impl.cs, Phase-4 — blocker-map R5). Go’s deepValueEqual keys its cycle-detection visited map on the values’ internal data words (v.ptr / v.pointer()) — eface addresses the managed bridge never populates — so the first slice/map/pointer comparison converted the null unsafe.Pointer slot and NREd (deepequal.cs:74 → unsafe op_Implicit; first operational hits: strings and bytes TestSplit/TestSplitAfter). The converter skips only deepValueEqual (manualConversionFuncs["reflect"]; DeepEqual itself stays auto — its body only touches the bridged ValueOf/Type/AreEqual), and the hand-owned form re-implements the recursion arm-for-arm with Go’s switch over the bridge’s boxed values: elementwise arrays/slices with the []byte fast path (Span.SequenceEqual standing in for bytealg.Equal); the nil-vs-empty slice distinction read from the REAL backing (m_array null ⟺ the golib default = nil — the public slice<T>.Source materializes a detached copy, so the impl reads m_array/m_low via cached reflection, the same pattern as the bridge’s IsNull/Value property reads); struct fields via goStructFields; maps compared key-by-key through the backing Dictionary (same-map identity short-circuits — Go’s “same map object” rule — and a missing key fails exactly like Go’s invalid MapIndex); pointer identity as ж<T>-box reference equality (one box per variable ≙ Go’s address equality, so the same slice/pointer is deeply equal to itself even holding NaN); IEEE float semantics (C# == on double — NaN ≠ NaN, like Go); funcs never deeply equal unless both nil. Cycle detection mirrors Go’s hard() step on managed identity: (pointer box | map Dictionary | slice backing array

Two defects the guard could not see (2026-07-26), one of them the guard itself. The recursion read the Value’s raw boxed field, but an ADDRESSABLE Value — a slice element, an array element, a struct field — carries its value behind addrBox (the ж<T> it aliases) and leaves boxed null, so every such read saw null on both sides and the identity short-circuits fired: DeepEqual([][]byte{[]byte("ab")}, [][]byte{[]byte("ac")}) was true — each element’s backing read as null, matched “same initial entry of the same underlying array”, and the elementwise walk never ran. The recursion now reads live (identical to boxed for a non-addressable Value) once per side and threads it through every arm. Second, mapBacking probed for a field assignable to the non-generic IDictionary, which a generated named-map wrapper does not have — it holds a map<K,V> struct, whose own backing store is one level deeper — so both sides of a named-map comparison resolved to null, ReferenceEquals(m1, m2) matched them as “the same map object”, and two named maps of equal length were deeply equal regardless of contents (identityRoot was blind the same way, so a named-map cycle was never detected either). The probe now takes that second step.

Neither showed up because the guard’s own comparison was vacuous: it printed with the builtin println, which writes to stderr, and the runners compare stderr by FIRST LINE only (Go’s panic reports carry a machine-specific stack trace, so a full stderr comparison can never match). 46 of its 47 assertions were unchecked. The guard now prints with fmt.Println — stdout, compared in full — and is extended to 47 cases: the original slice/struct/map/pointer/cycle set plus named maps (equal, differing value, differing key, differing length, self, nil-vs-empty), a named map as a struct field, a named map OF slices, and nil-map-key parity on both plain and named map[any]int. Counter-proven: pre- fix the [][]byte case and six named-map cases printed the wrong answer. (Solitaire is the only other output-compared project that prints solely through println; its comparison is vacuous for the same reason — recorded as a follow-up, together with tightening the runners’ stderr rule to a full compare whenever the exit code is not a panic.) The guard project references the full-conversion reflect (the baseline stub has none): its Directory.Build.targets redirects the emitted core\reflect reference to core\reflect — the Performance-suite pattern for settings the per-transpile csproj regeneration would otherwise clobber. Surfaced by the guard’s output comparison: golib’s print/println now render a bool as gc’s runtime printer does (true/false, not the BCL True/False).

A third defect: the FUNC arm asked the wrong question (2026-07-31). Go’s rule is “func values are deeply equal if both are nil; otherwise they are not deeply equal,” and the arm implemented only the second half — an unconditional return false — on the reasoning that two nil funcs would already have matched through the invalid == invalid rule at the top. That reasoning holds for a top-level nil func boxed as any (the null object, whose Value is invalid), and for nothing else: a nil func reached as a struct field — or a slice/array element, or a map value — is typed by its static func type, which by design makes it a VALID nil Value (see Value.Field). So any two structs carrying a nil func field were reported unequal, and asking IsNil() of both values is the fix. The observable form is a whole-struct comparison that can never succeed no matter how carefully the test normalizes the rest: compress/flate’s TestWriterReset nils fill/step/bulkHasher/bestSpeed, copies hashMatch, and substitutes tokens/window precisely so DeepEqual can compare everything else — and it failed at all ten compression levels. That was the package’s only failing test; with the arm corrected compress/flate validates 64 / 64. The diagnosis is worth recording because every individual field compared equal (through Field(i).Interface(), which re-boxes and so re-enters the invalid-value path) while the enclosing struct did not — the discrepancy between the two is what named the arm. The DeepEqual guard gains nine cases: nil-func struct fields equal, one side non-nil, a struct with a non-nil func field compared to itself (Go says not equal), differing non-func fields, nil funcs as slice elements and as map values, and the top-level nil/non-nil pair that the old arm did handle. Counter-proven by neutering the arm back to return false: the guard’s output comparison fails.

The Windows directory-entry walk (os/dir_windows_impl.cs, Phase-4 — os operational). Go’s (*File).readdir walks the buffer GetFileInformationByHandleEx fills by REINTERPRETING it as a Go struct — info := (*windows.FILE_ID_BOTH_DIR_INFO)(entry), then unsafe.Slice(&info.FileName[0], info.FileNameLength/2). That struct is managed-referent: its inline Go arrays (ShortName [12]uint16, the variable-length trailing FileName [1]uint16) convert to golib array<uint16> OBJECT references — 8 bytes each where the OS wrote 24 and 2 bytes inline — so the managed layout does not describe the buffer bytes at all. &info.FileName[0] addressed a zero-length array (System.IndexOutOfRangeException on the FIRST directory read: path/filepath.Glob, os.ReadDir, os.File.Readdirnames, and every test that walks a testdata directory), the fields after ShortName sit at the wrong offsets, and merely copying the reinterpreted struct hands the GC a fabricated object reference. No converter or golib change can rescue this — a managed array reference can never be laid out like an inline OS array — so it is the raw metal on non-native types arm of the conversion fork: the declaration is hand-owned.

Scope is one declaration, via the type-level registry (manualConversionFuncs["os"]["File.readdir"]); everything else in dir_windows.go (dirInfo.init/close, the pool, dirEntry) stays auto and keeps receiving converter improvements. The hand-owned form decodes the two entry layouts straight out of the byte slice at their documented offsets and never materializes a managed struct over OS memory — every read is an ordinary bounds-checked managed slice read, so there is no pointer, no pinning and no unsafe block in the file (a short or truncated buffer surfaces as an index panic rather than reading past the OS data). Offsets come from the Go declarations in internal/syscall/windows, which match the buffer bytes for every field Go reads: Go widens Win32’s CCHAR ShortNameLength to uint32, which shifts only ShortName — a field neither Go nor the impl reads — while FileID (96) and FileName (104) land identically either way. The auto newFileStatFromFileIDBothDirInfo / newFileStatFromFileFullDirInfo remain emitted but are now unreachable (their parameter is the unusable reinterpret); the impl builds the fileStat from the same offsets. ⚠ The registry key is name-keyed per package, so it also matches os.(*File).readdir in dir_unix.go — a -platforms linux/amd64 conversion of os would drop its (perfectly convertible) unix readdir. Same platform caveat as runtime’s lock_sema entries. No behavioral guard is expressible: the baseline src/core has no os package, so the guard is the operational one — go/doc/comment’s TestTestdata (filepath.Glob over testdata/) went from zero subtests ran to 54 enumerated.

The testing shim’s compile-only benchmark surface and CoverMode (core/testing/testing.cs). Capability-excluded test and benchmark declarations still compile — exclusion gates the run registry, not emission — so every member their bodies reference must exist even though the code never executes (a broken emission inside an excluded test blocks the whole package build; see the strings/bytes blocker map, B6). The B surface (N, Run, ReportAllocs, SetBytes, ResetTimer, StartTimer, StopTimer, Errorf, Fatal, Fatalf) is therefore compile-only: safe non-throwing no-ops, with the params-taking members carrying explicit ж<B> overloads exactly as T’s do (ref-like params Spans are outside the RecvGenerator’s synthesis). testing.CoverMode() returns "" — not a stub-lie but Go’s exact coverage-off value: the sole caller across the strings/bytes suites (strings’ TestIndexRune) branches on CoverMode() == "" and so takes the same path as an uncovered go test run. Guarded by TestingRuntimeTests.BenchmarkCompileSurfaceIsNoOpAndCoverModeReportsCoverageOff, which compile-references every member through both receiver shapes and asserts the coverage-off semantic — removing any member fails the suite at build.

The same rule extends to testing.F (2026-07-20). A Fuzz* declaration is classified disclosed-unsupported in the manifest exactly as a benchmark is (testConversion.go already emitted the fuzz/”deferred to Phase 4D” entry), but its converted body still compiles into the test assembly — and F simply did not exist, so math/big’s func FuzzExpMont(f *testing.F) (nat_test.go) failed the whole package build with CS0426 the type name ‘F’ does not exist in the type ‘testing_package’. F now mirrors B: a compile-only struct whose members are safe non-throwing no-ops, with explicit ж<F> overloads on the params-taking members. Its member set is Go 1.23’s full public surface for *testing.F — the TB members it inherits from the embedded common, plus its own Add and Fuzz — declared complete under the same anti-drift rule as TB above rather than trimmed to today’s callers. Fuzz takes a System.Delegate: a Go fuzz target’s signature is arbitrary (*testing.T followed by the fuzzed argument types), and the converted body is an explicitly-typed lambda, so C# infers its natural Action<…> and converts — no per-arity overload set is needed. Nothing is invoked and no seed corpus is retained, because there is no fuzzing engine to consume either. This is not math/big-specific: roughly seventeen stdlib packages ship fuzz targets (archive/tar, archive/zip, compress/gzip, encoding/csv, encoding/json, html, image/{gif,jpeg,png}, net/netip, time, syscall, …), every one of which would hit the identical build blocker.

Realizing an asm-backed arch layer with managed hardware intrinsics

Hand-owning an asm-backed declaration does not have to mean stubbing it. Where .NET exposes the same instructions the .s file issues — via System.Runtime.Intrinsics — the architecture layer can be ported for real, and the converted package gains genuine hardware acceleration rather than a fallback. hash/crc32 is the first of its kind (2026-07-24) and sets the pattern.

Go’s crc32_amd64.go declares three functions with no body — castagnoliSSE42, castagnoliSSE42Triple (the SSE4.2 CRC32 instruction) and ieeeCLMUL (PCLMULQDQ carry-less multiply folding) — implemented in crc32_amd64.s. Converted literally they become bodyless partials that the PartialStubGenerator fills with NotImplementedException, so crc32.go always took the slicing-by-8 fallback and the package’s own TestArchIEEE/TestArchCastagnoli skipped. crc32_amd64.cs is hand-owned ([module: go.GoManualConversion], whole-file) and the three functions are transcribed against System.Runtime.Intrinsics.X86Sse42.X64.Crc32 for the CRC32B/W/L/Q chain, Pclmulqdq.CarrylessMultiply + Sse2 for the fold and Barrett reduction, Sse41.Extract for the final PEXTRD. Every other declaration in the file is the converted output verbatim.

Three rules make this a repeatable recipe rather than a one-off:

Note the csproj constraint this recipe was written under: the converter regenerates each package’s .csproj on every transpile, and it sets AllowUnsafeBlocks from usesUnsafeCode alone – which was false here, so a hand-owned file could not use byte*/fixed/stackalloc and any setting added by hand was clobbered on the next run. That is no longer the only option (a file may now declare the requirement), but this recipe is still the better answer where it applies: it needs no compiler flag at all. The loads go through MemoryMarshal.GetReference(p.ToSpan()) plus Unsafe.ReadUnaligned<T> / Unsafe.Add, which need no compiler flag and read the slice’s real backing window (offset included) with no copy.

The payoff is that the package’s own test suite becomes the correctness oracle, which is what makes this pattern safe to repeat: TestArchIEEE and TestArchCastagnoli cross-check the intrinsics against the portable slicing-by-8 implementation over randomized buffers at 46 lengths chosen to straddle the 168*3=504 and 1344*3=4032 cutoffs, and enabling the arch path also routes TestGolden/TestGoldenMarshal through it against known vectors. All 8 hash/crc32 Test functions match go test, with nothing skipped and no disclosed divergences (Phase-4 validated package; see docs/Roadmap.md). Confirmed by positive control — corrupting a fold constant and the CRC32B tail turns exactly the arch-dependent tests red while the portable-only ones stay green.

A cross-package //go:linkname PULL emits a forwarder, not a throwing stub

A bodyless function carrying //go:linkname <local> <pkgpath>.<func> (a three-field directive naming another package) is a PULL — the function has no body of its own and links to another package’s (often unexported) symbol. golang.org/x/sys/windows’s LazyDLL/LazyProc reach the Go runtime’s DLL loaders this way:

//go:linkname syscall_loadlibrary syscall.loadlibrary
func syscall_loadlibrary(filename *uint16) (handle Handle, err Errno)

Left as an ordinary bodyless declaration, it would emit a partial that the PartialStubGenerator turns into a throwing stub — dead DLL loading. The converter (visitFuncDecl.go) instead recognizes the directive and emits a forwarder body that calls the target, bridging any nominal num:uintptr type difference through uintptr (the linked signatures are structurally identical, so a mismatch is only between two such types):

internal static (ΔHandle handle, Errno err) syscall_loadlibrary(ж<uint16> filename) {
    var (1, 2) = syscall.loadlibrary(filename);
    return ((ΔHandle)(uintptr)1, (Errno)(uintptr)2);
}

Pointer/slice/string parameters (filename) pass through unchanged (the same golib type on both sides); a uintptr-kind parameter is passed (uintptr)p and a uintptr-kind result returned (LocalType)(uintptr)r. The bridge is scoped to uintptr-kind types (uintptr and named types whose underlying type is uintptr, e.g. Handle/Errno) — precisely the case where the two linked signatures name the same value under two different nominal C# types. A sized integer (int32/int64/…) is the same C# type on both sides and passes through bare; routing it through uintptr instead narrows it on 32-bit and does not even bind ((uintptr)timeout handed to an int64 parameter — sync’s runtime.blockUntilEmptyFinalizerQueue pull, CS1503).

The target alias is whatever the importing file actually emitted for the target package, not the bare last path segment: an explicit import r "runtime" is looked up in the file’s recorded import aliases, and otherwise the canonical alias is taken through the same collision-rename the import machinery applies. That rename is not hypothetical — a file that pulls from runtime while any go.runtime.* namespace is in scope emits using Δruntime = runtime_package;, because a bare runtime alias would bind the namespace, and a forwarder spelled runtime.<fn> is then CS0234 (sync’s oncefunc_test.go).

Forwarding is gated on an explicit whitelist of hand-implemented targets (linknameForwardTargetssyscall.loadlibrary/loadsystemlibrary/getprocaddress, the native P/Invokes in core/syscall/dll_windows.cs, plus runtime.blockUntilEmptyFinalizerQueue, the finalizer-queue drain that sync’s and runtime’s own tests pull and that mfinal.cs answers with GC.WaitForPendingFinalizers). This is not optional prudence: a linkname target is indistinguishable at conversion time from any other bodyless assembly/intrinsic Go function — syscall.loadlibrary and runtime.reflectcall are both bodyless //go: asm in Go — so only the whitelisted targets are known to have a real C# implementation to call. Every other linkname pull stays a bodyless stub, the pre-forwarder behavior: a method-receiver PUSH (//go:linkname X reflect.(*rtype).Align, reflect’s badlinkname.go “pushes linknames of the methods”), a same-package pull (//go:linkname unusedIfaceIndir reflect.ifaceIndir inside reflect), and an unimplemented intrinsic (//go:linkname call runtime.reflectcall) would each otherwise emit an uncompilable forwarder (a nonexistent reflect.(*rtype)/runtime.reflectcall member, or a package alias that doesn’t exist for the package’s own name). Extend the whitelist when a new native linkname target gains a hand-written C# implementation — and remember the accessibility half: Go’s linkname crosses the package boundary the way C# public does, so an unexported target (which the exported-ness rule emits internal) is invisible to the forwarder in the pulling assembly and must be widened where it is declared (runtime.blockUntilEmptyFinalizerQueue is public in the hand-owned mfinal.cs for exactly this reason). Guarded by TestRecurseLinknameForwarder (asserts the whitelisted syscall.loadlibrary forwarder body + the uintptr result bridge, and that a non-whitelisted runtime.reflectcall target stays a stub).

A whitelisted target may be ORDINARY CONVERTED GO, and then the converter widens it itself. Every entry above answers a hand-written body. time.registerLoadFromEmbeddedTZData is the first that does not: time/zoneinfo_read.go declares it with a real Go body, time authorizes the pull with the matching one-arg handle, and time/tzdata’s init() calls it — the only edge between the two packages, since time/tzdata imports errors, syscall and unsafe and never time. Two consequences follow from that, and both are general:

Until this landed, a blank import _ "time/tzdata" threw NotImplementedException out of a module initializer and took the whole program down before main, which is exactly what the blank-import init forcing made reachable. It also turns out to be what lets time’s test suite load zone data at all: with tzdata registered, loadLocation falls back to the embedded database when the GOROOT lib/time/zoneinfo.zip path a test hard-codes does not resolve from the C# host’s working directory.

A linkname target implemented as a golib BUILTIN forwards to a bare, unqualified call (linknameForwardBuiltins, a sibling map of Go linkname target → golib builtin name). Some Go compiler intrinsics live in runtime and are linked into another package by symbol, but their go2cs implementation is a golib builtin — in scope UNQUALIFIED via each converted project’s using static go.builtin, so the forwarder emits <builtin>(args) with no package qualifier (an empty alias is the sentinel writeLinknameForwarder reads to drop the <alias>. prefix). The canonical case is maps.Clone: Go implements its worker as runtime.mapclone (//go:linkname mapclone maps.clone) and the maps package pulls it as a bodyless func clone(m any) any carrying //go:linkname clone maps.clone — a same-package-named target (maps.clone) whose real definition is elsewhere, so it is neither a native whitelist target nor a normal pull, and was left a throwing PartialStubGenerator stub (every maps.Clone/maps.Copy/maps.DeleteFunc test threw NotImplementedException: clone: external (assembly or cgo) function is not implemented). It now forwards:

internal static any clone(any m) {
    return mapclone(m);
}

builtin.mapclone(any m) is Go’s runtime.mapclone at golib level: it recovers the boxed map’s concrete key/value types through IMap.CloneMap() (a default interface method on IMap<TKey, TValue>, so both the concrete map<K, V> and the generated named-map wrappers get it with no source-generator change — no reflection) and returns a fresh map<K, V> populated from the source’s entries. The clone’s backing Dictionary is independent — Go’s shallow clone (keys/values copied by ordinary assignment), so mutating the clone never touches the original — and a nil map clones to nil. This is what carries the maps package to full Phase-4 validation (14/14 tests vs go test; the 6 Clone/Copy/DeleteFunc tests previously threw). Extend linknameForwardBuiltins when another linkname intrinsic gains a golib builtin. Guarded by the MapCloneLinkname behavioral test — the exact //go:linkname clone maps.clone shape in a main package, cloning a map[string]int, mutating the clone (overwrite/add/delete) and asserting the original is unchanged, output-compared vs go run; proven to emit the throwing stub against the un-fixed converter.

A cross-package //go:linkname PUSH resolves per recorded disposition — forwarder or announced panic

The PULL above is one of two directions, and the converter long handled only that one. A PUSH runs the other way: the defining package carries the body and names ANOTHER package’s declaration as the symbol it defines, while the consuming side is an ordinary bodyless func under a one-argument `//go:linkname

` handle. `runtime/mgc.go` pushes into `unique`, `runtime/mheap.go` into `internal/weak`: ```go // unique/handle.go — the CONSUMER: bodyless, one-arg handle (Go's authorization for the push) //go:linkname runtime_registerUniqueMapCleanup func runtime_registerUniqueMapCleanup(cleanup func()) // runtime/mgc.go — the PUSHER: an ordinary body naming the consumer's symbol //go:linkname unique_runtime_registerUniqueMapCleanup unique.runtime_registerUniqueMapCleanup func unique_runtime_registerUniqueMapCleanup(f func()) { … } ``` Nothing linked the two, so the consumer's declaration fell to the [`PartialStubGenerator`](#source-generators) and threw on first call — `unique.Make`'s `setupMake.Do(registerCleanup)` took `net/netip`'s initializer and `encoding/gob`'s `TestNetIP` with it. The consuming side now resolves to one of **two** emissions, chosen by a disposition recorded per pair in `linknamePushTargets` (`linknameOperations.go`), keyed by the consumer's own `.`: ```csharp // unique/handle.cs — FORWARDED: the pushed body is ordinary converted Go, so call it //go:linkname runtime_registerUniqueMapCleanup internal static void runtime_registerUniqueMapCleanup(Action cleanup) { Δruntime.unique_runtime_registerUniqueMapCleanup(cleanup); } // internal/weak/pointer.cs — UNHONORABLE: announce the pair, never fabricate a body //go:linkname runtime_registerWeakPointer internal static @unsafe.Pointer runtime_registerWeakPointer(@unsafe.Pointer _) { throw panic("go2cs: //go:linkname push runtime.internal_weak_runtime_registerWeakPointer -> internal/weak.runtime_registerWeakPointer is not honored: the pushed body walks mheap_ span metadata the managed model does not populate; internal/weak wants a hand-owned managed weak reference"); } ``` The accessibility half mirrors the pull's: a forwarder calls the pushing definition **across an assembly boundary**, so `packageFuncAccess` emits that definition `public` from the reverse index `linknamePushSources`. The pull arm requires the target's own one-arg handle as Go's authorization; a push carries its authorization on the **pushing** side instead, so this arm reads the registry alone. Reaching the pusher can be the only edge to its package, so the path is queued for a project reference exactly as the pull queues its target (`linknameTargetAlias`, now shared by both arms, also resolves the file's actual using-alias — `unique/handle.cs` spells `Δruntime`, not `runtime`). **The consumer has TWO shapes, and a row records which one it is** (`bareDecl`, added 2026-08-08). The example above is the **handle** shape — a one-arg `//go:linkname ` above the bodyless declaration, Go's modern way of opening a symbol to a push, used by `unique` and `internal/weak`. The standard library also pushes into a **bare** shape that predates that convention: a bodyless declaration with *no directive at all*, carrying only a prose comment saying where the body lives. `syscall` and `os` still use it, and it is every bit as legal: ```go // syscall/env_unix.go — the BARE consumer: no directive of its own func runtime_envs() []string // in package runtime // runtime/runtime.go — the PUSHER, where this pair's only directive lives //go:linkname syscall_runtime_envs syscall.runtime_envs func syscall_runtime_envs() []string { return append([]string{}, envs...) } ``` The matcher originally required the handle unconditionally (it returned early on `funcDecl.Doc == nil`, before even reading the registry), so the bare shape could never resolve — it fell to the `PartialStubGenerator`, and because `syscall.envs` is a package-level var *initialized* from that call, the throw came out of `syscall`'s type initializer and took `os.init()`, and every Linux program that so much as touches `fmt`, with it. Nothing on Windows could see it: `env_unix.go` is `//go:build unix || (js && wasm) || plan9 || wasip1`, so that declaration does not exist in the Windows corpus, and the registry's original census was taken against a Windows-only emission. The shape is **recorded per row rather than inferred**, so the match fails closed in both directions: a handle row will not forward a bare declaration, a bare row will not forward one carrying a handle, and a two-arg directive (a PULL — a different mechanism) is rejected by both arms. Neither shape is verifiable from the consumer's own syntax, which is the whole reason this registry is curated, so the shape belongs in the same recorded judgment as the disposition (`linknamePushDeclMatches`, `visitFuncDecl.go`). The `syscall.runtime_envs` row forwards honorably rather than plausibly: `runtime.envs` really is populated in the managed model, by the hand-owned `runtime/goenvs_impl.cs` module initializer, so the forwarder hands back the real process environment. Guarded two ways — `TestRecurseLinknamePush` carries the 2x2 over shape (`unauthorized` and `bare` are *syntactically identical* and differ only in what their row records, which is what proves the recorded shape is what decides), and `TestLinknamePushRegistryMatchesGoSource` checks every row against the real Go source in GOROOT: the consumer's declaration exists, is bodyless, matches the recorded shape, and the pushing side really does carry the two-arg directive the row vouches for. **Why a curated registry rather than general detection.** The same reason the pull whitelist exists, plus one more that is structural: **the converter never sees the pushing package's directives while converting the consumer.** A package is converted from its own syntax; its dependencies contribute types, not comments — and the pusher is not even guaranteed to be a dependency. So a bodyless one-arg-handle func is indistinguishable at conversion time from an ordinary assembly stub, and the disposition has to be recorded. Go 1.23 carries ~200 pushes outside `cmd/`; the converted corpus exposes **eleven** of them as bodyless one-arg-handle declarations, and linking those wholesale would be a regression dressed as a feature — `time`'s timer trio is already answered by `time_impl.cs` and a converter-emitted body would collide with it, while `internal/syscall/windows`'s `stdcall` wrappers and `internal/coverage/cfile`'s linker-section walk push bodies the managed model cannot run at all. Each entry is therefore a judgment, recorded with its reasoning beside the key. **The unhonorable arm is the inverse-atomic rule in emission form.** `internal/weak`'s two halves reach the span allocator (`registerWeakPointer` → `getOrAddWeakHandle` → `spanOfHeap` → `throw("getWeakHandle on invalid pointer")`; `makeStrongFromWeak` re-derives an object pointer from a heap address). A forwarder there would either fault or — far worse — hand back a plausible-looking pointer derived from garbage, which is exactly the "populate a field whose read cannot be honored" move the rule forbids. The panic is deliberately a **Go panic naming both halves of the pair and the reason**, not the generator's `NotImplementedException`: the declaration is not an unimplemented assembly stub, it is a real Go contract this conversion has decided it cannot keep, and the first caller to hit it should land on the hand-own the row actually needs. That hand-own has since LANDED — see [*`internal/weak.Pointer`*](#internalweakpointer--the-clr-already-has-weak-references-so-the-runtime-handle-becomes-one) below — so these two rows no longer describe the deployed corpus, where `pointer.cs` is marked and never regenerated; they describe what a conversion into a root that does *not* already carry the hand-own emits, which must still be the loud pair. The reason string now names the file to reach for. By contrast `unique`'s pushed body is **ordinary converted Go** — it makes a `chan struct{}` and starts a goroutine that drains it and calls the callback — so the managed model runs the real thing: the registration succeeds and the cleanup goroutine parks on the channel. Nothing signals it, because the converted runtime's `clearpools()` is driven by Go's own GC, which does not run. That is Go's OWN behavior for a program whose GC never fires (the intern map simply keeps its entries), not a fabricated answer — the distinction the two arms turn on. Guarded by `TestRecurseLinknamePush`, which runs the real `-recurse` converter over a two-package module fixture with its dispositions injected for the test's duration, and asserts all four arms: the forwarder body, the pushing definition's publicization, the pair-naming panic, and that both an unregistered handle and a registered declaration *without* a handle stay bodyless stubs. (A behavioral project cannot reach this mechanism — the registry is keyed by the consumer's import path, and the behavioral harness converts each package alone, so neither a fixture path nor cross-package discovery is available to it. The pull forwarder is guarded at the same layer, and for the same reason, by `TestRecurseLinknameForwarder`.) **A push whose consumer is an EXTERNAL TEST package needs no new machinery — the key simply spells the test package path** (2026-08-12). `runtime/metrics.go` pushes its test-only name reader into the package's own test suite: ```go // runtime/metrics/description_test.go — the CONSUMER, in package metrics_test (handle shape) //go:linkname runtime_readMetricNames func runtime_readMetricNames() []string // runtime/metrics.go (package runtime) — the PUSHER //go:linkname readMetricNames runtime/metrics_test.runtime_readMetricNames func readMetricNames() []string { … } ``` The `-tests` conversion's `convertTestVariant` resets package state from the external variant's own `packages.Package`, so `currentPackagePath` is already `runtime/metrics_test` while its files convert — the registry row `"runtime/metrics_test.runtime_readMetricNames"` matches through the exact code path every production consumer uses, and the emitted forwarder (`return global::go.runtime_package.readMetricNames();`) replaced the throwing stub that held the package's `TestNames` at 1 of 2. Any production package pushing into its own `_test` package takes the same shape. The registry guard learned the location half: an external test package has no GOROOT directory of its own, so `TestLinknamePushRegistryMatchesGoSource` resolves `_test` to the base package's directory and scans its `_test.go` files (only those whose package clause carries the `_test` suffix — in-package test files belong to `` itself). The same package's `Read` entry point is the registry's second **measured** unhonorable row, and the measurement is worth recording: a forwarder was tried first, and the pushed body ran — through the hand-owned managed `metricsLock` — all the way to `readMetricsLocked`'s slice-header reconstruct (`*(*[]metricSample)(unsafe.Pointer(&sl))` over a raw first-element address), which no managed pointer can alias: the fabricated slice read garbage `@string` names. The deployed corpus routes around the seam instead of through it — `runtime/metrics/sample.cs` is hand-owned and its `Read` marshals names in and computed `(kind, scalar, pointer)` out through the public `runtime.readMetricsManaged` shim (`managed_impl.cs`, the `registerPoolCleanup` pattern), preserving the batch semantics while the metrics table and every compute closure stay auto-converted — so the `runtime_readMetrics` row exists for a conversion into a root WITHOUT the hand-own, where the bodyless declaration reappears and must announce the wall rather than fabricate past it. `runtime/metrics` validates 2 of 2 on this arrangement. ### `internal/concurrent.HashTrieMap` — a managed map where Go seeds itself from `MapType().Hasher` `internal/concurrent` is the whole of `unique`'s storage, and `unique` is `net/netip`'s address interner — so this one type sits in front of `unique`'s entire suite, `net`'s last package-initializer root and `encoding/gob`'s `TestNetIP`. Go 1.23's implementation is a lock-free hash-trie, and **every bit of its behavior comes from one runtime descriptor read**: ```go func NewHashTrieMap[K, V comparable]() *HashTrieMap[K, V] { var m map[K]V mapType := abi.TypeOf(m).MapType() ht := &HashTrieMap[K, V]{ root: newIndirectNode[K, V](nil), keyHash: mapType.Hasher, keyEqual: mapType.Key.Equal, valEqual: mapType.Elem.Equal, seed: uintptr(rand.Uint64()), } return ht } ``` `Hasher` is a raw function pointer into the hashing machinery the compiler emits for `map[K]V`; `Key.Equal`/`Elem.Equal` are its matching bit-compare thunks. All three take `unsafe.Pointer`s and mean *"hash / compare the bytes AT this address"* — and **the managed reflection bridge cannot honor that contract**. An address in the CLR names no value: two boxes holding equal strings sit at different addresses, and a pointee containing references moves across a GC. An address-derived hash would therefore stop `unique.Make("hello")` agreeing with itself, which is the exact inverse of the package's purpose. Populating `Hasher` anyway — with anything plausible — is barred by the **inverse of the atomic rule**: a descriptor field whose read cannot be honored must stay EMPTY, because a half-populated descriptor converts a loud construction failure into a map that is silently wrong. (Reflection increment 8 rooted the row there and reported it *not landable in the bridge*.) So the literal conversion compiles and can never run: `NewHashTrieMap` threw inside the package initializer of every `unique` consumer, taking `net/netip` and every dependent with it. **The ruling (2026-08-03) is the `sync` precedent applied one level up: hand-own the whole file, and keep the SEMANTICS rather than the mechanism.** `sync`'s Mutex/RWMutex/WaitGroup are reimplemented on `SemaphoreSlim`/monitors because Go's sleeping semaphore is co-designed with the state machine and cannot be emulated; here the coupling is to the descriptor surface instead of to the scheduler, but the fork is the same one — the raw-metal arm of the S1 fork (see [`Baseline-vs-FullConversion.md`](../src/archived/Baseline-vs-FullConversion.md)). `src/core/internal/concurrent/hashtriemap.cs` carries `[module: go.GoManualConversion]` and contains **no trie at all**. The exported API and its concurrency contract are preserved exactly; the store is a `ConcurrentDictionary`, whose guarantees line up member for member: | Go member | Managed mechanism | Semantic note | |:--|:--|:--| | `NewHashTrieMap[K, V]()` | `Ꮡ(new HashTrieMap<K, V>(store: new mapStore<K, V>()))` | the store is a CLASS, so a by-value copy of the struct shares one map — exactly what Go's `root *indirect[K,V]` pointer gives | | `(*HashTrieMap).Load(key)` | `TryGetValue`; miss returns `(*new(V), false)` as `@new().ValueSlot` | `[GoRecv]`, so the RecvGenerator still mints the `ж<…>` overload `unique` binds | | `(*HashTrieMap).LoadOrStore(key, value)` | `TryGetValue` → `TryAdd` retry loop | exactly one caller of a racing set observes `loaded == false`; `GetOrAdd` is a single call but cannot report WHICH outcome occurred, and `unique.Make` depends on that answer | | `(*HashTrieMap).CompareAndDelete(key, old)` | `ContainsKey` gate → `TryRemove(KeyValuePair)` | the pair overload is an atomic compare-and-remove under `EqualityComparer.Default`; the gate reproduces Go's order (a missing key returns false *without* comparing values) | | `(*HashTrieMap).All()` | closure over the store's enumerator | ConcurrentDictionary's enumeration is **weakly consistent** — never throws on concurrent mutation, visits each live key once, promises no order — which is Go's documented contract verbatim, and is what lets `unique`'s cleanup pass `CompareAndDelete` while it walks | | zero `HashTrieMap` | `storeOf` lazily installs the store (`Interlocked.CompareExchange`) | Go 1.23's zero value is unusable (nil `root`/`keyHash`); nothing depends on that panic, and the same `gateOf` idiom `sync.Mutex` uses removes a whole class of null dereference | | `keyHash` + `keyEqual` | `EqualityComparer.Default` | see below — verified to BE Go's `==` for every key shape the corpus interns | | `valEqual` | `EqualityComparer.Default`, guarded by `mustBeComparable` | Go's value comparison panics for an INTERFACE `V` holding an uncomparable dynamic type (`V comparable` admits `any` since Go 1.20, moving the check to run time); the guard mirrors that panic instead of letting the comparer answer a question Go refuses to. Inert for a non-interface `V`, resolved once per instantiation | **The equality/hash bridge is the correctness question, and it was measured, not reasoned.** Go hashes and compares keys by K's own `==`; the managed implementation uses `EqualityComparer.Default`. For every key shape the converted corpus actually interns these agree: * **`ж`** (`unique`'s own `map[*abi.Type]any`) implements `IEquatable<ж>` as pointer IDENTITY with a matching identity hash, and `abi.TypeFor()` interns one descriptor box per `System.Type` — so one Go type always presents one key, and a second `TypeFor` call finds the first call's entry. * **A `[GoType]` struct** — `net/netip`'s `addrDetail{isV6 bool; zoneV6 string}`, the shape `unique` actually interns — carries a generated field-wise `Equals` over `==` plus a `HashCode.Combine` of the same fields, which is Go's struct `==` exactly. It does **not** implement `IEquatable`, so `EqualityComparer.Default` routes through the `object` override; that lands on the same comparison, at the cost of one box per lookup. * **`@string`** compares and hashes by CONTENT, as Go's string `==` does — verified with two keys built from distinct backing storage. **Two further walls sit BEHIND this one**, both uncovered by making `unique` reachable for the first time and both outside this file: 1. ~~**A cross-assembly `//go:linkname` PUSH never links.**~~ **CLOSED** — see [*A cross-package `//go:linkname` PUSH resolves per recorded disposition*](#a-cross-package-golinkname-push-resolves-per-recorded-disposition--forwarder-or-announced-panic) above. The forwarder machinery handled the PULL direction only (a bodyless declaration naming another package's symbol); `runtime` pushes the other way — `//go:linkname unique_runtime_registerUniqueMapCleanup unique.runtime_registerUniqueMapCleanup` (`mgc.go`), `//go:linkname internal_weak_runtime_registerWeakPointer internal/weak.runtime_registerWeakPointer` (`mheap.go`) — and the *consuming* package's bodyless declaration was left for the [`PartialStubGenerator`](#source-generators) to fill with `NotImplementedException`. `unique`'s registration now FORWARDS to runtime's converted body; `internal/weak`'s two halves stay unlinked **by ruling** (the pushed bodies walk `mheap_` span metadata) and announce the linkname pair rather than fabricate one. The remedy they name — a hand-owned managed weak reference — has since landed; see [*`internal/weak.Pointer`*](#internalweakpointer--the-clr-already-has-weak-references-so-the-runtime-handle-becomes-one) below. 2. **`abi.TypeFor()` is silently WRONG for an interface `T`.** Its non-interface branch returns an interned descriptor; the interface branch is `TypeOf((*T)(nil)).Elem()`, and `Type.Elem()` for `Kind == Pointer` reinterprets the descriptor as a `PtrType` (`Ꮡt.Reinterpret<Type, PtrType>()`) and reads `.Elem` — which under the managed layout lands on the descriptor's `Equal` field. `TypeFor()` and `TypeFor()` therefore return a `System.Func<unsafe.Pointer, unsafe.Pointer, bool>`, not a `ж` at all. Shared generics let that object be *stored* into `ConcurrentDictionary<ж, any>` without a cast check, and the first real key comparison then dispatches `IEquatable<ж>.Equals` on a delegate → `EntryPointNotFoundException`. The old trie never dispatched anything on a key's runtime type (it compared raw addresses through `keyEqual`), which is why a corpus-wide bridge defect could hide behind it. **Not hardened against here on purpose** — tolerating a type-unsafe key would be the same "plausible but fake" move the inverse-atomic rule forbids; the loud failure is the correct behavior and the fix belongs in `abi`. **Guarding measurement.** `encoding/gob` holds at **95 of 106** and `TestNetIP`'s root moves from `NewHashTrieMap` → `ArgumentException: Delegate to an instance method cannot have null 'this'` to the linkname stub above (no row regresses; `TestNetIP` is the only gob row whose closure reaches `unique` at all). `unique` itself goes **0 → 1 of 19** and, more usefully, stops being a one-root wall: its 15 identical `TypeInitializationException` rows resolve into five distinct downstream roots (the two linkname pushes, a `GCHandle: Object contains references` on `abi.Escape`, an `IndexOutOfRangeException` in `makeCloneSeq`'s `slice` enumeration, and the `TypeFor` hole above). The hand-owned file is also its package's only Go file, which makes `internal/concurrent` fully hand-owned — see [`Baseline-vs-FullConversion.md`](../src/archived/Baseline-vs-FullConversion.md) for what that does to the package's `.csproj`/`package_info.cs`/`README.md`, and for the seeded-reconvert proof in both directions. ### The Linux syscall bottom — ONE libc P/Invoke, and why `r2` is exact rather than approximate Go reaches the Linux kernel through a single assembly function. `internal/runtime/syscall/asm_linux_amd64.s` loads the call number into `RAX` and `a1..a6` into `RDI, RSI, RDX, R10, R8, R9`, executes `SYSCALL`, and reports `RAX` and `RDX`: ```go // internal/runtime/syscall/syscall_linux.go — no body, no linkname, no Go anywhere func Syscall6(num, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2, errno uintptr) ``` Everything funnels through it: `syscall`'s `RawSyscall`/`RawSyscall6`/`Syscall`/`Syscall6`, every generated wrapper in `zsyscall_linux_amd64.cs` (open, read, write, close, stat, getrlimit, …), and this package's own `EpollCreate1`/`EpollWait`/`EpollCtl`/`Eventfd`, which is how `internal/poll` and the netpoller reach the kernel. Converted, it is a bodyless partial, so the [`PartialStubGenerator`](#source-generators) filled it with a throw — and because `syscall`'s own `init()` calls `Getrlimit(RLIMIT_NOFILE)` before `os` is usable, that one throw stopped every Linux program before `fmt.Println` could emit a byte. The hand-own (`core/internal/runtime/syscall/linux/syscall_linux_impl.cs`) binds **glibc's `syscall(2)`** rather than reproducing the instruction — a user ruling, taken over the alternative of mapping each wrapper onto a .NET API. One P/Invoke lights the whole generated surface at once; the per-call alternative is N hand-owns, each independently guessing at semantics the kernel already defines exactly. ```csharp [DllImport("libc", EntryPoint = "syscall", SetLastError = true)] private static extern nint libc_syscall(nint number, nint a1, nint a2, nint a3, nint a4, nint a5, nint a6); ``` Three details separate a faithful binding from a plausible one. Each was **measured on linux/amd64** (glibc 2.35, .NET 9) rather than argued from the ABI documents, because each is exactly the kind of claim that reads as obviously true and is expensive when it is not: * **The variadic.** C declares `long syscall(long number, ...)`; this declares seven fixed native ints. That is correct under SysV AMD64 — integer-class variadic arguments ride the same registers as fixed ones, with the seventh spilling to the stack, which is precisely where glibc's hand-written `syscall.S` reads `a6`. Proven with a real six-argument call: `mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)` returned a live mapping that `munmap` then released. (`AL`, which a true variadic call sets to the vector-register count, is unused by `syscall.S`.) * **`r2`.** Go's contract returns `RDX`, which libc's wrapper cannot hand back — the reason the [run-layer finding](/phase4/FINDING-linux-run-layer.html) listed it as an open question. It does not need to: the Linux x86-64 convention clobbers only `RCX` and `R11`, so `RDX` still holds what entered the kernel, and the asm's `MOVQ DX, BX` observes `a3` unchanged. Returning `a3` is therefore not a stand-in for `r2` on this architecture — it *is* `r2`. Probed under the real Go runtime: `syscall.Syscall6(SYS_GETPID, …, a3=0xDEADBEEF, …)` returns `r2=0xdeadbeef`. The failure path zeroes `r2`, which the shim mirrors. * **`errno`.** libc collapses the kernel's whole `[-4095, -1]` error band to a `-1` return and reports the positive errno out of band — the same number Go's asm produces by negating the raw return — and `SetLastError` lets the CLR capture it before managed code can perturb it. Probed: `openat(AT_FDCWD, NULL)` returns `-1` with `Marshal.GetLastPInvokeError() == 14` (EFAULT). **One divergence is disclosed rather than papered over:** a syscall that legitimately returns `-1` as a *success* value is indistinguishable from a failure through libc, and would report a stale errno. Go's asm has no such ambiguity because it tests the raw return against the whole band. Nothing the converted corpus reaches behaves that way, and the only true fix is an instruction-level bottom the managed model cannot express. **The pointer half needed nothing.** These wrappers pass addresses as `uintptr` — `Getrlimit` emits `RawSyscall(SYS_GETRLIMIT, (uintptr)resource, (uintptr)Ꮡrlim, 0)` — and golib's `ж` → `uintptr` operator does not hand out a token: it calls `EnsureStableAddress()` to pin the managed storage and returns a real address, so the kernel genuinely reads and writes through it. The residual risk is per-struct **layout**, not addressing, and it is the same open class as the Windows non-blittable-wrapper census (see `zsyscall_windows_impl.cs`); `Rlimit` is two `uint64`s, so the first crosser was blittable and worked untouched. **Portability, stated in the file for the arm64 increment:** `asm_linux_arm64.s` puts `a1..a6` in `X0..X5` with the number in `X8` and reads `r2` from `X1` — which holds `a2`, not `a3` — so an arm64 flavor must echo `a2` and must re-run the `r2` probe there rather than inherit this answer. The variadic shortcut is likewise a per-platform judgment (standard AAPCS64 passes variadic integer args in the same registers as named ones; Apple's arm64 ABI deliberately does not), and a musl target would likely need a `NativeLibrary.SetDllImportResolver` fallback. The binding itself is `[LibraryImport]` rather than `[DllImport]` — the corpus-wide FFI convention, adopted for the whole surface at once; see [Every P/Invoke is source-generated](#every-pinvoke-is-source-generated) for what that buys and what it cost to reach. ### Every P/Invoke is source-generated Every native binding in the converted corpus — all fifteen, across five hand-owned files and two operating systems — is `[LibraryImport]`. There is no `[DllImport]` left, and a new one is a mistake rather than a style choice. The reason is one property of the two attributes and nothing else. `[DllImport]` answers a signature it cannot marshal by marshalling something *else*: a non-blittable struct becomes a temporary copy, and a kernel that writes through the pointer writes into a temporary the caller never reads. That is a wrong **answer**, produced silently, at run time. The source generator refuses to emit the call at all, so the same mistake is a compile error with a line number. That distinction is not hypothetical here. It is the exact defect `zsyscall_windows_impl.cs` exists to repair — three times over, at 172, 592 and 568 bytes — where the converted `Timezoneinformation`, `win32finddata1` and `ProcessEntry32` hold their inline `WCHAR[]` buffers as managed `array` references and the kernel wrote native records over smaller managed objects. The remedy each time was an explicitly blittable mirror plus a pointer, and until now that discipline was enforced only by a reviewer noticing. It is now enforced by the compiler, which is the whole return on the migration: the residual risk of routing Go's kernel boundary through managed structs is per-struct **layout**, and this makes layout a build-time question. **What the property costs, and the mechanism that pays it.** `SYSLIB1062` requires `true` unconditionally — even for an all-`nint` signature — because the generated stub is written in terms of pointers. That property is converter-generated from `usesUnsafeCode`, an *emission* fact that observes only C# the converter wrote, so a hand-owned file was structurally unable to ask for it: setting it by hand is undone by the next reconvert overlay. [`[module: go.GoRequiresUnsafe]`](#a-hand-owned-file-can-declare-that-it-needs-unsafe) is that mechanism, and `time` is the package where it is genuinely load-bearing rather than merely honest — its converted emission contains nothing unsafe at all. **The rejection census.** Twelve of the fifteen declarations the generator accepted **unchanged**, which is itself the finding: `exec_windows.cs`'s five and `zsyscall_windows_impl.cs`'s five were already all-pointer, all-blittable, because both files were written under exactly the discipline the generator checks. Three needed a different signature, and each rejection was a latent hazard rather than a formality: | Declaration | Rejected for | Became | |---|---|---| | `LoadLibraryExW` (`dll_windows.cs`) | `CharSet` has no `[LibraryImport]` equivalent | `StringMarshalling = StringMarshalling.Utf16` — and *not* merely equivalent: UTF-16 marshalling of a `string` is a **pin**, so the stub hands Windows the string's own storage instead of a copy | | `GetProcAddress` (`dll_windows.cs`) | `CharSet.Ansi` + `BestFitMapping=false` + `ThrowOnUnmappableChar=true`; the latter two are unsupported outright | `byte*`. The mechanical translation (`StringMarshalling.Custom` over `AnsiStringMarshaller`) would have kept the transcode while silently dropping the guard that made it safe — an unmappable rune stops throwing and becomes `'?'`, i.e. a lookup of a *different symbol*. There was never a transcode to preserve: Go passes this entry point a `*byte` with no codepage step anywhere, and the caller already holds that NUL-terminated buffer. The old form decoded it through the ANSI codepage and the marshaller re-encoded it back, lossy in both directions | | `SetWaitableTimer` (`time_impl.cs`) | `SafeWaitHandle`, and `bool` in both directions | The handle parameter becomes `nint` with `DangerousAddRef`/`DangerousRelease` taken **visibly** in the caller — SafeHandle marshalling is a runtime service, and `[DllImport]` supplied the reference count invisibly, so nothing in the source said where it was taken. `bool` becomes `[MarshalAs(UnmanagedType.Bool)]` on the return and the parameter: its native width is a marshalling decision (Win32 `BOOL` is four bytes, C++ `bool` is one), the runtime marshaller's silent default happened to be right, and the generator refuses to guess. `UnmanagedType.Bool` *is* the four-byte `BOOL`, so the ABI is unchanged and the choice is now written down | **One converter-side change was required, and it is the interesting one.** `go2cs-gen`'s `PartialStubGenerator` fills every bodyless `partial` method with a throwing stub, which is how the converter emits Go's asm/cgo functions. A `[LibraryImport]` declaration is also a bodyless `partial` method — and **source generators cannot observe each other's output**, so `PartialImplementationPart` is null from there and the declaration looks exactly like an unimplemented asm function. Stubbing it produced two implementing parts and failed the whole package with `CS0757`, for all twelve P/Invokes at once, and only once a hand-own adopted the form. The generator now skips any partial declaration carrying an attribute that obliges a *different* generator to implement it; the test is on the attribute, because the attribute is the obligation. A second such attribute (`JSImport`, `GeneratedComInterface`, …) is added to that set rather than worked around at the call site. Guarded by the `LibraryImportPartial` behavioral test, which is deliberately a *compile* assertion — both failure modes (`SYSLIB1062` from a missing declaration, `CS0757` from an over-eager stub) are compile failures — carrying a hand-owned `[LibraryImport]` of exactly the corpus's shape, plus a module initializer that calls it, so a program that prints at all is one whose generated marshalling stub reached the kernel. ### The scheduler brackets are a faithful no-op, not an omission `syscall_linux.go` pulls the pair that wraps every non-`Raw` kernel call: ```go //go:linkname runtime_entersyscall runtime.entersyscall func runtime_entersyscall() //go:linkname runtime_exitsyscall runtime.exitsyscall func runtime_exitsyscall() ``` Forwarding is the shape this converter reaches for first (see the PULL section above), and it is unavailable here — not marginally: runtime's `entersyscall` opens with `getcallerfp()` / `getcallerpc()` / `getcallersp()`, raw-metal frame intrinsics with no managed realization, and hands them to `reentersyscall`, which drives the P state machine across `sched`, `mp.oldp` and `casgstatus`. None of that state exists in the managed model, so a forwarder faults on the first intrinsic. `core/syscall/linux/syscall_linux_impl.cs` implements both as empty. That is a *realization*, not a stub: the pair's whole job is to release the P around a blocking call so other goroutines can run on another M, and to reacquire one after. Both are `func()`, they compute nothing any caller consumes, and no converted code reads state they would set — while the obligation they discharge is discharged by the host instead, since a converted goroutine is a .NET thread, a blocking syscall blocks that thread as the CLR expects, and thread-pool injection keeps other work running. This is the same judgment `syscall_impl.cs` records for `runtimeSetenv`/`runtimeUnsetenv`, and it is *not* the fabricated-answer failure mode the project rules against — there is no answer to fabricate. The package's other bodyless declarations (`rawSyscallNoError`, `rawVforkSyscall`, `runtime_doAllThreadsSyscall`, `cgocaller`) are separate questions and stay announcing stubs. The file lives in `linux/` rather than flat because the declarations it implements are linux-only; a flat implementing part would have no defining declaration on Windows. See *Hand-owns have a platform*. ### `runtime.argslice` — forwarding and populating are ONE change `os.init()` on unix assigns `Args = runtime_args()`, a bare-shape PUSH whose pushed body is ordinary converted Go: `append([]string{}, argslice...)`. Adding the registry row alone would have *worked* and been wrong — `runtime.argslice` is filled by `goargs()` reading the argv vector off the initial stack, a raw address the CLR does not hand out, so `os.Args` would have come back **empty**: not an error, just a plausible-looking wrong answer. `core/runtime/goargs_impl.cs` is the sibling of `goenvs_impl.cs` and closes that: a `[ModuleInitializer]` — the faithful stand-in for schedinit's slot, running once before any converted Go code in the assembly — fills `argslice` from `Environment.GetCommandLineArgs()`, which is the managed mirror of the same vector (measured under `dotnet prog.dll alpha beta`: `{".../prog.dll", "alpha", "beta"}` — the program followed by its arguments, exactly Go's shape; *not* `Environment.ProcessPath`, which names the host, and *not* Main's `args`, which omits element zero). The row and the companion therefore land together, or the pair announces a falsehood. **Windows is untouched in both halves**, and by Go's own construction rather than by an exception: `goargs()` itself opens `if GOOS == "windows" { return }`, and the companion keeps that guard verbatim, so `argslice` stays unset exactly as in Go — which is what `runtime_boring.cs`'s `boring_runtime_arg0` already documents and depends on ("On Windows, argslice is not set"). ### `runtime.sysDirectory` — the same pairing, one consumer shape further out `internal/syscall/windows.GetSystemDirectory` is a PUSH from `runtime/os_windows.go`, and it is the HANDLE consumer shape rather than `argslice`'s bare one: `security_windows.go` carries its own one-arg `//go:linkname GetSystemDirectory` above the bodyless declaration, so the registry row records `bareDecl: false`. Everything else is the `argslice` lesson repeated — which is the point, since it shows the rule is about the STATE behind the push, not about either syntax. The pushed body is one line (`unsafe.String(&sysDirectory[0], sysDirectoryLen)`), and the buffer behind it is filled by `initSysDirectory()` calling `stdcall2(_GetSystemDirectoryA, …)` from `osinit`. **Neither half runs in the managed model** — `osinit` is the runtime bootstrap the converter emits already marked not-run, and `stdcall` bottoms out in `asmstdcall`, a [`PartialStubGenerator`](#source-generators) throw — so the buffer stays all-zero and its length `0`. A forwarder alone would have returned `""`, turning `net`'s `hostsFilePath = windows.GetSystemDirectory() + "/Drivers/etc/hosts"` into `"/Drivers/etc/hosts"`. `core/runtime/windows/os_windows_impl.cs` closes it the way `goargs_impl.cs` does: a `[ModuleInitializer]` fills the buffer from `Environment.GetFolderPath(SpecialFolder.System)`. Two details are reproduced rather than tidied. Go appends a separator (`sysDirectory[l] = '\\'; sysDirectoryLen = l + 1`), so the answer really does end in a backslash and `net`'s concatenation really does produce `C:\Windows\System32\/Drivers/etc/hosts`; and Go's `throw("Unable to determine system directory")` is mirrored rather than softened into a short answer that would read as real. What the missing row cost is out of all proportion to one symbol, and worth recording as a shape to look for: the throw came out of a package-level VAR INITIALIZER, so it surfaced from `net_package`'s type initializer — **every `httptest` consumer died in `net`'s cctor**, whatever it was actually testing. ### Long-path awareness is process SETUP, and golib does what Go's `osinit` does `runtime.osinit` is not only where `goenvs`/`goargs`/`initSysDirectory` run; it is also where every Go Windows binary opts its own process into long-path handling. `initLongPathSupport()` checks for Windows 10.0.15063 or later and then sets the undocumented `IsLongPathAwareProcess` bit in the PEB's bit field. ntdll's path canonicalizer consults that bit, so with it set a plain, un-prefixed path longer than `MAX_PATH` reaches the kernel intact. A converted program is an ordinary .NET process and gets none of that. The divergence is measured, not theoretical: at a 434-character path, `Directory.SetCurrentDirectory` fails `ERROR_FILENAME_EXCED_RANGE` (206, `0x800700CE`) where Go's `os.Chdir` succeeds. `MkdirAll` works on both sides because `os.fixLongPath` prefixes `\\?\` explicitly; `Chdir` hands the plain path to `SetCurrentDirectoryW`, and `\\?\` is no escape hatch there — `SetCurrentDirectory` rejects the extended form outright. `golib/builtin.WindowsLongPaths.cs` therefore sets the same bit from `InitializeGoLib`, golib's analogue of `osinit`, under the same version guard, and defensively: it is a parity measure rather than a prerequisite, since the `\\?\` fallback still works with the flag clear. **Why not an `` carrying `longPathAware`.** It reaches the same PEB flag and was the first remedy proposed, but Windows honors a manifest's declaration only when the machine-wide policy `HKLM\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled` is *also* 1. Go asks for neither the manifest nor the policy — so a manifested converted binary would still diverge from the Go binary on a default install, where that value is 0, and the manifest measures as a fix only on machines where the policy happens to be on. Doing what Go does is also the smaller change: no per-project manifest artifact and nothing in the emitted `.csproj`, which is what keeps the behavioral corpus and every banked `.tests.csproj` byte-identical (CNR compares the emitted `.csproj`). **What is deliberately left alone.** `initLongPathSupport` also sets `internal/syscall/windows.CanUseLongPaths`, which makes `os.fixLongPath` stop adding the prefix. That flag lives in a converted package golib cannot reference — golib is the root of the dependency graph — and `false` is the conservative side: the extended-prefix spelling still reaches the kernel with the PEB bit set, so the only difference is which spelling it sees. Guarded by `syscall`'s own `TestGetwd_DoesNotPanicWhenPathIsLong`, which skipped on `Chdir failed: … The filename or extension is too long` until this landed, and passes on both sides now. ### `internal/weak.Pointer` — the CLR already has weak references, so the runtime handle becomes one `internal/weak` is `unique`'s liveness model, one layer below `internal/concurrent.HashTrieMap` and in front of the same consumers. The package's entire body is two `//go:linkname` declarations, and both pushed bodies live in `runtime/mheap.go`: ```go func Make[T any](ptr *T) Pointer[T] { ptr = abi.Escape(ptr) // force the pointee onto the heap var u unsafe.Pointer if ptr != nil { u = runtime_registerWeakPointer(unsafe.Pointer(ptr)) } runtime.KeepAlive(ptr) return Pointer[T]{u} } func (p Pointer[T]) Strong() *T { return (*T)(runtime_makeStrongFromWeak(p.u)) } ``` `registerWeakPointer` → `getOrAddWeakHandle` → `getWeakHandle` → `spanOfHeap` walks `mheap_` span metadata to find or hang a `specialWeakHandle` off the span, and `makeStrongFromWeak` loads a word out of that handle and **re-derives an object pointer from the address**. The managed model populates no span metadata, and *"what object lives at this address?"* is a question the CLR does not answer at all — so the pair is registered UNHONORABLE in `linknamePushTargets` and each half announces itself by name (see [*A cross-package `//go:linkname` PUSH resolves per recorded disposition*](#a-cross-package-golinkname-push-resolves-per-recorded-disposition--forwarder-or-announced-panic)). The announcement is what pointed at this hand-own; this is what it was pointing at. **The ruling is the `sync.Mutex` / `internal/concurrent.HashTrieMap` precedent, and it fits better here than anywhere it has been applied before, because the CLR has first-class weak references of its own.** `src/core/internal/weak/pointer.cs` carries `[module: go.GoManualConversion]` and contains no span walk. The contract translates clause for clause: | Go's contract | Managed mechanism | |:--|:--| | `Make(ptr)` never fails; `Strong()` yields the ORIGINAL pointer while the referent is reachable, and nil once the collector has identified it unreachable — **before** a finalizer can resurrect it | `WeakReference<ж>` over the `ж` box, **SHORT** (`trackResurrection: false`); Go's handle likewise clears ahead of finalization | | A weak pointer does not keep its referent alive | Nothing on the `Pointer` → `handle` → referent path is a strong reference | | Weak handles are **unique and canonical per byte offset into an object**, so weak pointers made from pointers that compare equal compare equal — and pointers to different offsets within one object do not | A `ConditionalWeakTable` keyed on the referent ALLOCATION whose value is a `ConcurrentDictionary` keyed on the GO POINTER. `ж`'s own `Equals`/`GetHashCode` ARE Go's pointer identity, including "two fields of one struct are different addresses" | | Equality is retained after the referent is reclaimed | `Pointer` holds the handle STRONGLY, so the handle outlives the referent and keeps answering — it just answers nil forever after | | A weak pointer made after a resurrection is NEWLY UNIQUE | The table entry dies with the referent (that is what a `ConditionalWeakTable` key is), so a later `Make` mints a fresh handle | | `abi.Escape(ptr)` — force the pointee out of the frame | Nothing to force: a `ж` IS a heap allocation from construction, whatever its pointee's type | | `runtime.KeepAlive(ptr)` | `GC.KeepAlive` — load-bearing, not decorative: the referent is reachable from `Make`'s frame only through the argument, and every use of it is finished before the return | **Why the canonical table does not pin what it indexes — the one subtle claim.** A `ConditionalWeakTable` is an EPHEMERON: its value is kept alive only while the KEY is independently reachable, and edges *from* the value *to* the key do not count as reachability. The key is `ж.ReferentObject` — the same lifetime question `runtime.SetFinalizer` already keys on (`mfinal.cs`), answered the same way: an element ref resolves to its backing storage, a field ref to the containing allocation, and a standard heap box to itself. The value holds the `ж` boxes strongly, as dictionary keys, which is deliberate — for a field or element pointer the box is a per-expression view that would otherwise die long before the struct does, and `Strong()` must keep returning it; the ephemeron makes that safe. The handle holds only a `WeakReference`. Composing the three, **a box is reachable exactly when its referent is**, so one plain `WeakReference` tracks the REFERENT's liveness for every pointer shape, not merely the standard-box shape. One shape is deliberately not modelled, and it is Go's error case too: a box that ALIASES A NATIVE ADDRESS names unmanaged storage the collector does not own, so its managed reachability is not the Go question. Go answers by faulting (`throw("getWeakHandle on invalid pointer")` — a non-heap address has no span); here it would observe an eventual nil rather than a fabricated pointer, the safe direction. Nothing in the converted corpus takes a weak pointer to one. **A second, independent defect this closes.** `Pointer[T]` is written out rather than left to `[GoType]`, because the generated struct equality is field-wise `==` **guarded on every type parameter carrying an `IEqualityOperators` constraint** (`TypeGenerator`'s `hasEqualityOperators` → `AllGenericTypesHaveConstraint`), and Go's `Pointer[T any]` carries none — so the emitted body was literally `Equals(other) => false /* missing equality constraints */`. Two weak pointers to one object NEVER compared equal, contradicting the type's own doc comment and silently defeating `unique.Make`'s `m.CompareAndDelete(value, wp)`, which could therefore never match and never evict a dead entry. Equality is the *whole reason* the runtime canonicalizes the handle, so it is hand-written here — and as `IEquatable<Pointer>`, which the generated form does not implement, so `EqualityComparer<Pointer>.Default` reaches it without boxing on every lookup. ⚠ **The gate itself is over-conservative and the defect is corpus-wide**: it disqualifies a struct when ANY type parameter lacks the constraint, even when no field's type mentions that parameter. `unique.Handle[T]` is the other confirmed victim — its single field is a `ж`, which defines `==` for every `T`, yet its generated `Equals` is `false` too, so `unique.Handle` values never compare equal either. That is a generator fix rather than a hand-own, and is left to its own arc. **Guarding measurement.** `internal/weak`'s own suite now links and runs (`go2cs -tests -test-action all "/src/internal/weak" src/core/internal/weak`): **`TestPointerEquality` PASSES against `go test`** — the canonicalization clause, the hardest one, validated end to end. `TestPointer` and `TestPointerFinalizer` do not, and the reason is the roster's already-named **`codegen-liveness`** class rather than the weak model: both hold the referent in a live C# local (`bt`) across the `runtime.GC()` that is supposed to kill it, where Go's per-safepoint liveness maps drop it at its last use. (Neither is *disclosable* — `TestPointerFinalizer` does not fail an assertion, it blocks forever on `<-done` waiting for a finalizer that a still-rooted object can never queue — so `internal/weak` does not bank.) A dedicated probe separates the two — a referent created and dropped inside a `[MethodImpl(NoInlining)]` helper is reported collected, and only a weak pointer that had `Strong()` called on it *earlier in the same frame* stays alive: ``` PASS Strong() is nil once the referent is unreachable (never probed) FAIL Strong() is nil once the referent is unreachable (probed first) ``` with a self-keyed `ConditionalWeakTable` control and the two-level table control both collecting, so the ephemeron reasoning above is confirmed rather than assumed. `unique` reads the same way from the other side: every `TestHandle` subtest that gets far enough reports **only** `v0 != v1` (the `[GoType]` equality gate above) and never `v0.Value() != v1.Value()` — i.e. both `Make` calls interned the *same* `ж`, which is exactly what canonical weak handles plus `LoadOrStore` are for. `pointer.go` is this package's only Go file, so marking it makes `internal/weak` **fully hand-owned**: the driver `continue`s on `unmarkedFileCount == 0` and stops re-emitting `internal.weak.csproj`, `package_info.cs` and `README.md`, and no `pointer.cs.auto` review sibling is produced — the position `internal/godebug` and `internal/concurrent` are already in. The marker census moves **39 → 40**. ### `unique.clone` — a raw-offset string walk the managed model cannot express, hand-owned after the `@string` window made it GC-fatal Go's `unique.clone[T]` rewrites every string field of a just-interned value in place, addressing each by raw ABI offset so an interned handle never keeps a large parent string alive: ```go func clone[T comparable](value T, seq *cloneSeq) T { for _, offset := range seq.stringOffsets { ps := (*string)(unsafe.Pointer(uintptr(unsafe.Pointer(&value)) + offset)) *ps = stringslite.Clone(*ps) } return value } ``` The converted form — `(ж<@string>)(uintptr)((uintptr)Ꮡvalue + offset)` followed by a `.Value` write — adds a **Go ABI** offset to the transient interior address of the movable `ж` heap box, whose CLR field layout is unrelated to Go's ABI (`EnsureStableAddress` cannot pin a box whose `T` contains references, and for `[2]struct{a string}` the +16 offset is outside the 8-byte `array` reference that is the entire CLR value). Every such store landed on the box's OWN fields. While `@string` was a single 8-byte reference the damage was a type-confused slot holding a valid object — silently wrong values, nothing the collector trips over. When `@string` became an offset/length **window** (`fc6d8c179`, r57c — 16 bytes: `byte[]` + two `int`s), the same store's integer tail began landing in an adjacent GC-scanned reference slot, and the next collection — which `unique`'s own `drainMaps` forces via `runtime.GC()` — walked a garbage pointer and fail-fasted the process with `COR_E_EXECUTIONENGINE` (0x80131506). Bisected, and reproduced in ~25 lines against golib alone, by the 2026-08-12 unique-bisect lane (the board's scout-batch-1 `unique` entry holds the full record). `src/core/unique/clone.cs` therefore carries `[module: go.GoManualConversion]` — the standard S1 managed-referent remedy — with **only `clone` departing from the conversion**. Its contract is "makes a copy of value, and MAY update string values found in value with a cloned version": the cloning is a retention optimization, never a semantic requirement, so the hand-own does the `T == string` case exactly (a right-sized `stringslite.Clone`, no address arithmetic — worth more, not less, now that `@string` windows share backing) and returns aggregate values unchanged. The one observable divergence from Go is retention: an interned aggregate's strings keep sharing their original backing arrays. Equality, identity and intern-map drainage — what `unique.Make` is *for* — are unaffected. `makeCloneSeq` and the `cloneSeq` builders remain the verbatim conversion (pure descriptor arithmetic, still validated by `TestMakeCloneSeq`), so a `clone.cs.auto` review sibling is emitted on every reconvert as usual. ### `internal/cpu.getGOAMD64level` — a BUILD constant, so the honest answer is the baseline Go declares `getGOAMD64level() int32` bodyless and implements it in `cpu_x86.s`, where it is not code at all but a compile-time constant selected by the `GOAMD64_vN` define the toolchain sets from `go env GOAMD64`: ```asm TEXT ·getGOAMD64level(SB),NOSPLIT,$0-4 #ifdef GOAMD64_v4 MOVL $4, ret+0(FP) #else #ifdef GOAMD64_v3 MOVL $3, ret+0(FP) #else #ifdef GOAMD64_v2 MOVL $2, ret+0(FP) #else MOVL $1, ret+0(FP) #endif ``` The question it answers is *which amd64 microarchitecture level was this BINARY built for*, never *which level does this CPU support* — the two differ constantly, and Go depends on the difference: `doinit`'s option table gates the `sse3`/`avx`/`avx512` GODEBUG knobs on `level < 2/3/4`, so a v1 build running on a v3 machine keeps them switchable. go2cs emits portable C# with no GOAMD64 define, no microarchitecture-gated emission and no instruction-set floor above the amd64 baseline, so the faithful answer is the same constant Go's own assembly produces for a build without a `GOAMD64_vN` define: **1**. That is a measured property of the emission rather than a placeholder value, and probing the host through `System.Runtime.Intrinsics.X86` would answer a *different question* — the inverse-atomic rule's exact prohibition, since the returned number would look truthful while meaning something else. `cpu_x86_impl.cs` returns it under `[module: go.GoManualConversion]`, registered as `manualConversionFuncs["internal/cpu"]["getGOAMD64level"]`, so the converter leaves the standard placeholder comment where the bodyless partial was. A/B footprint: **one corpus file**. Demonstrated consumer: `internal/cpu`'s own `TestDisableSSE3`, whose first statement is `if GetGOAMD64level() > 1 { t.Skip(…) }` — against the unimplemented `PartialStubGenerator` stub that guard was an infrastructure-error, and it was the package's only divergence (7 of 8). With the constant in place the test reads 1, walks on into `runDebugOptionsTest`, and skips exactly where Go does: `internal/cpu` validates **8 of 8**. ### `StructField.Tag` is a REAL read — the converter has always emitted the tag, nothing had ever read it The converter emits a tagged field's Go struct tag verbatim at the declaration: ```go NamedCurveOID asn1.ObjectIdentifier `asn1:"optional,explicit,tag:0"` ``` ```csharp [GoTag(@"asn1:""optional,explicit,tag:0""")] public asn1.ObjectIdentifier NamedCurveOID; ``` `GoTagAttribute` aliases `System.ComponentModel.DescriptionAttribute`, so the text survives into metadata. It has done so since tags were first emitted — and until 2026-08-09 **nothing in the corpus read it**: golib's Go-field projection (`GoReflect.GoFieldInfo`) carried no tag, and the reflection bridge's `rtype.Field` left `StructField.Tag` at its zero value. Every converted struct therefore reported as UNTAGGED, and every tag-driven decoder in the standard library — `encoding/json`, `encoding/xml`, `encoding/asn1` — saw a type with no tags at all. The failure that surfaced it is subtle rather than loud, which is the point: `encoding/asn1` omits an `optional` field whose value `DeepEqual`s its zero, so with the tag invisible `crypto/x509`'s `marshalECPrivateKeyWithOID(k, nil)` MARSHALLED the nil `NamedCurveOID` instead of omitting it, and `makeObjectIdentifier` rejected the empty arc list — `asn1: structure error: invalid object identifier`, the whole of `crypto/ecdsa`'s `TestEqual`. Nothing about the message points at reflection. `GoFieldInfo` gains `Tag`, read from the declaration's attribute (the promoted-embed arm reads it off the backing box field, since Go allows a tag on an embedded field); `rtype.Field` surfaces it as `StructTag`. `Offset`, `PkgPath` and `Anonymous` stay **unpopulated**: no truthful read backs them here, and a descriptor field whose read cannot be honored must not be made to look truthful. (Guarded by the `ReflectStructTagCopy` behavioral test — raw tag text, `Tag.Get` for two keys, `Tag.Lookup`'s absent-vs-empty distinction, and untagged fields answering `""`, output-compared vs Go.) ### `reflect.Copy` is bridged element-wise — the auto form is a flat two-header memory move `reflect.Copy` reinterprets BOTH operands' data words as `unsafeheader.Slice` headers (`*(*unsafeheader.Slice)(dst.ptr)`) and hands them to `typedslicecopy`. That is a raw memory move with no managed form, and on the bridge's never-populated `ptr` slot it dereferences a nil `ж` outright. `encoding/asn1`'s `parseField` copies every parsed `[]byte` into its destination through it, so this was `crypto/x509`'s `ParsePKCS8PrivateKey` and therefore the second half of `crypto/ecdsa`'s `TestEqual` — reached only once the tag fix above let the marshal succeed. The bridge copies element-wise through the same golib container interfaces every other bridged container method uses, which keeps the ALIASING exact rather than approximating it: a slice VALUE windows the backing store it shares with its parent, so an indexer write is a write the parent sees — precisely what `typedslicecopy` does to the same memory. Kind and element-type validation mirror Go's, including the documented special case where `src` may be a `String` when `dst`'s element type is `byte`; a nil container on either side copies nothing, matching Go's zero-length header. (Guarded by the `ReflectStructTagCopy` behavioral test — slice←slice truncating at either side, slice←string, an addressable array through `Elem()`, a nil destination, and a window slice whose copy must be visible in the ORIGINAL backing array.) ### `reflect.Type.Name()` — a DEFINED type HAS a name even when its underlying type is a composite Go's rule is about DEFINEDNESS, not shape: `Name()` reports the type's name within its package for any defined type and `""` only for a type that was never defined — `[]int`, `map[string]int`, `*T`, `chan int`, `interface {}`, `struct { … }`. `type testSET []int` is defined, so its `Name()` is `"testSET"` even though its underlying type is a slice. The bridge had that backwards. Go's own `rtype.Name()` gates on the descriptor's `TFlagNamed` bit (`abi.Type.HasName()`), which a **synthesized** `abi.Type` never carries, so the hand-owned `rtype.Name()` substituted a shape test — `GoReflect.ElementType(st) is not null`, i.e. "does this type have an element type?". That is true of a defined container exactly as it is of an unnamed one, so every `type S []T` / `[N]T` / `map[K]V` / `chan T` / `*T` in the corpus reported no name at all. The tell was already in the same descriptor: `PkgPath()` reads the SAME managed nesting and answered `"main"` for those types while `Name()` answered `""` — a pair Go's own model cannot produce, since a type with a package path is by definition a defined type. The visible symptom was one byte. `encoding/asn1`'s `getUniversalType` distinguishes a SET from a SEQUENCE on the type's name and nothing else: ```go if strings.HasSuffix(t.Name(), "SET") { return false, TagSet, true, true } return false, TagSequence, true, true ``` so `Marshal(testSET([]int{10}))` produced `300302010a` where Go writes `310302010a` — `0x30` SEQUENCE for `0x31` SET, with no error, no panic and no other divergence anywhere in the encoding. The gate is now `GoReflect.HasGoName`, the managed stand-in for `TFlagNamed`. It mirrors `GoTypeName` ARM FOR ARM, because `Name()` IS that method's output with the package qualifier trimmed — the two disagreeing would let a type report a name it does not have, or hide one it does. False for exactly the arms that render Go structurally: the raw golib containers matched by open generic definition (`slice<>`/`array<>`/`map<,>`/`channel<>`/`ж<>`), `object` (`interface {}`), `EmptyStruct` (`struct {}`), an anonymous-struct lift (`[GoType("dyn")]` without a `[GoLocalName]`, which would make it a named function-local type), and the pointer-sourced adapter that stands for `*T`. True everywhere else — including the predeclared scalars, since Go's `int` IS a named type. The distinction the fix turns on is that a DEFINED container is not a golib container: the converter emits it as its own wrapper type that merely IMPLEMENTS the container interface, which is why the open-generic-definition test separates the two where an element-type probe cannot: ```go type intSET []int type byteArray [4]byte type stringMap map[string]int type intChan chan int type intPtr *int ``` ```csharp [GoType("[]nint")] partial struct intSET; [GoType("[4]byte")] partial struct byteArray; [GoType("map[@string, nint]")] partial struct stringMap; [GoType("chan nint")] partial struct intChan; [GoType("ж")] partial class intPtr; ``` Three further answers change with it, all in the same direction and none of them a value Go can produce: `interface {}`, `struct {}` and a lifted anonymous struct used to return their STRUCTURAL spelling from `Name()` (there is no dot to trim, so the whole string came back) and now correctly return `""`. `String()` was never affected — it has no such gate and rendered all of these correctly throughout, which is why the defect stayed invisible to `%T` and to the `ReflectliteTypeName` guard. (Guarded by the `ReflectStructTagCopy` behavioral test, which pairs each of the five named shapes with its unnamed control and re-runs asn1's own `HasSuffix(Name(), "SET")` decision. Measured on `encoding/asn1`'s converted suite: **37 of 38**, up from 35 — it closes `TestMarshal` #37 and also `TestCertificate`, whose "sequence tag mismatch" and empty RDN name had been left unattributed on the board and are the same root, since its `RDNSequence` is a `[]RelativeDistinguishedNameSET`.) **Still open, and dormant:** `abi.Type.HasName()` itself remains `false` for every synthesized descriptor. `internal/reflectlite.rtype.Name()` is the ordinary converted Go body and gates on it, so it answers `""` for EVERY type — strictly worse than what `reflect` had. Nothing in the corpus calls it (reflectlite's consumers, `context` and `errors`, use only `String`/`Kind`/`Comparable`/ `AssignableTo`/`Implements`), so it is recorded rather than fixed. Populating the bit would ALSO change `directlyAssignable`'s `T.HasName() && V.HasName()` short-circuit — which is currently over-permissive in both packages — and that is a corpus-wide assignability change, not a naming one. ### `abi.Type`'s SPECIALIZATIONS are synthesized, not downcast — `StructType()` / `ArrayType()` Go's `(*structType)(unsafe.Pointer(t))` is the **prefix-downcast** idiom: the linker really allocated a `structType` and handed out a pointer to its embedded `Type` header, so casting back reaches the sub-record. The section on `Reinterpret` above names this as the one case the managed arm deliberately does not cover — nothing sits behind a `ж` but an `abi.Type` — and these are the two sites where converted code took that cast anyway. The failure is not the contained wrong read the address route usually gives. `Reinterpret` correctly **refuses** to alias managed storage for a reference-bearing pair (aliasing would fabricate object references), so it fell through to the raw address and read `ΔStructType`'s fields out of the memory that follows the descriptor's value slot. Probed on `abi.TypeFor[testStringStruct]()`: ``` Fields.Length 8830452760576 <- an address fragment read as a slice length Fields.Capacity 16 <- the descriptor's OWN Size_, bleeding through the shifted view ``` `m_array` happened to land on a real heap object, so indexing it threw `IndexOutOfRangeException` rather than access-violating — a caught CLR type-safety break. That is **six of `unique`'s nineteen rows**, thrown on the first iteration of `unique.buildStructCloneSeq`, and `internal/reflectlite`'s `NumField`/`Len` read the same garbage. Both specializations are therefore hand-owned in `internal/abi/type_impl.cs` (registered in `manualConversionFuncs` as `Type.StructType` / `Type.ArrayType`, so the converter emits a placeholder comment for the Go bodies) and **synthesized from the descriptor's carried `System.Type`**, exactly as the descriptor itself is: | Field | Answer | |---|---| | `StructType.Fields[i].Typ` | `synthType` of the projected Go field type, dims-stamped from the declaring zero instance | | `StructType.Fields[i].Offset` | the field's **Go** (amd64) byte offset | | `ArrayType.Len` / `.Elem` / `.Slice` | the descriptor's carried array dims, the element descriptor, and `[]T`'s | The offsets are Go's numbers, not the CLR's — a Go `string` is 16 bytes where `@string` is an 8-byte reference — and they come from the SAME walk that stamps a descriptor's `Size_` (`GoReflect.GoFieldOffsets`, factored out of `GoSizeOf`'s struct arm), so a field's `Offset` and its struct's `Size_` cannot disagree. `unique`'s `cloneSeq` values are the demonstrated consumer: `struct{ z float64; b string }` → offsets `[8]`, `[2]struct{ a string }` → `[0 16]`, `[3]string` → `[0 16 32]`, each matching `go test` exactly. Two things are deliberately **not** invented, following the r39d rule that a descriptor field whose read cannot be honored must not be populated to look truthful. A descriptor with no `System.Type`, or a struct holding a field whose Go size is unknowable (one unknown size makes every later offset a guess), answers Go's **nil** — which every Go caller already tests. And `StructField.Name` / `StructType.PkgPath` stay the zero `ΔName`: a `ΔName` is a pointer into the linker's name blob and every reader of one walks it with `addChecked` raw-address arithmetic, the same route that produced the garbage above, whereas Go's own `ΔName.Name()` answers `""` for a nil `Bytes` — so the zero value is a state the format *defines* rather than a fabrication. A named field descriptor already comes from `reflect`'s hand-owned `rtype.Field` over `GoReflect.GoFields`, and no converted caller of `abi.StructType` reads a field name. Same defect class, **still open** and deliberately not chased here: `Type.Elem()`, `MapType()`, `FuncType()`, `InterfaceType()`, `Key()` and the free `Len()` reinterpret the same way. `Elem()`'s pointer arm is the mechanism behind the board's separate "`abi.TypeFor()` is silently wrong for an interface `T`" root. Guarded by `GolibTests.GoStructLayoutTests` (Go offsets and sizes for the exact shapes `unique`'s `TestMakeCloneSeq` exercises, plus alignment padding — removing the per-field alignment rounding fails `FieldOffsets_ApplyGoAlignmentPadding`), and measured by `unique`'s own suite: **1 → 4 of 19 matched**, with all six `IndexOutOfRangeException` rows gone and the three `TestHandle` ones moved on to the `internal/weak` linkname root behind them. ### Realizing the runtime TIMER contract (`Sleep` / `newTimer` / `stopTimer` / `resetTimer`) `time`'s four timer entry points have no Go body — they are `//go:linkname`'d into `runtime/time.go` — so the converter emits them as bodyless `partial`s that the [`PartialStubGenerator`](#source-generators) fills with `NotImplementedException`. A stub throw on a timer path is uniquely destructive: it lands on whichever goroutine touched the timer, and an unrecovered panic in *any* goroutine terminates the process, so every package that so much as called `time.Sleep` once was unreachable. `time_impl.cs` supplies the four bodies (the same supplemental-companion mechanism as `math_impl.cs` and the clock reads above it), and the whole model is a single `_impl.cs` region — no converter or golib change. **Service model: one heap, one thread — Go's own pre-per-P design.** Go keeps timers in per-P heaps run by whichever P first notices a deadline; the managed model is one deadline-ordered heap serviced by one dedicated background thread, the shape Go itself used before per-P timers (the old runtime `timerproc`). One thread is sufficient *and* ordering-faithful for a specific reason: the only two callbacks package `time` ever installs are `sendTime` (a **non-blocking** channel send) and `goFunc` (which only starts a goroutine), so no timer callback can occupy the service thread and delay a later deadline. Callbacks are collected under the lock and invoked after releasing it, in deadline order. **Precision: the same OS object Go uses.** `System.Threading.Timer` is *not* the mechanism, because its resolution is the Windows timer tick — measured at ~15 ms on the development host, which would make a 1 ms Go timer fire 15× late and let two timers less than a tick apart fire **out of order**. Both `Sleep` and the service thread instead wait on a Windows high-resolution waitable timer (`CreateWaitableTimerExW` + `CREATE_WAITABLE_TIMER_HIGH_RESOLUTION`), which is precisely what the Go runtime creates for its own sub-tick sleeps (`runtime/os_windows.go` `createHighResTimer`/`usleep`), cached **per thread** exactly as Go caches it per M. The wait is driven off the same monotonic source `runtimeNano()` reads, so timer deadlines stay coherent with `Now()`/`Since`/`Sub`; a truncation in the 100 ns due-time unit can only wake early, and the deadline loop re-waits the remainder, preserving Go's "at least `d`" guarantee. Where no high-resolution timer exists the fallback waits coarsely to within a millisecond and spins the remainder — correct, but tick-quantized, which is the whole reason the high-resolution path is preferred. The interop is `[LibraryImport]` over `nint` and `ref long`, and this file is where the migration to it cost the most: the source generator marshals no `SafeHandle` and guesses at no `bool`, so the handle's reference count is now taken visibly in `Arm` and both `bool`s carry an explicit `UnmanagedType.Bool`. It also needs `/unsafe`, which `time`'s converted emission does not, so `time_impl.cs` carries a [`[module: go.GoRequiresUnsafe]`](#a-hand-owned-file-can-declare-that-it-needs-unsafe) declaration — the mechanism that exists because the csproj is regenerated on every transpile. See [Every P/Invoke is source-generated](#every-pinvoke-is-source-generated). **Hidden state keyed by BOX IDENTITY.** Go's `runtime.timeTimer` carries the timer state in fields *after* the two `time` can see — sleep.go's "extra fields after the channel, reserved for the runtime and inaccessible to users". The managed equivalent hangs a `runtimeTimer` record (`when`/`period`/`f`/`arg`/`gen`) off the `ж` box's **reference identity** through a `ConditionalWeakTable`: `stopTimer`/`resetTimer` are always handed the very box `newTimer` returned. Weak-keyed, so an unreferenced `Timer` stays collectible (Go 1.23 recovers unreferenced timers) — while an *armed* timer's state is independently kept alive by the service heap, which is what makes a bare `time.After(d)`, whose `Timer` is dropped on the spot and only the channel kept, still fire. **The Stop/Reset contract and the fire race.** Both report `when > 0` — Go's `timer.stop`/`timer.modify` compute `pending` exactly that way — so *pending* means armed and not yet fired, and `when` doubles as the arm marker (0 = stopped, or a one-shot that fired). Every change to `when` bumps a generation counter, and heap entries carry the generation they were queued with; a `Stop` or `Reset` therefore cancels an already-queued firing **without** removing it from the heap (Go leaves the stale entry too, marked `timerModified`/`timerZombie`). One lock guards the heap *and* every timer field — Go's finer-grained per-timer + per-P scheme exists to scale across Ps, and with a single heap there is one lock and hence no lock-ordering hazard — so a `Stop`/`Reset` racing the firing callback can neither double-fire nor lose a re-arm: either the service thread already took the callback (and `when` is 0, so `pending` correctly reports false), or the generation bump invalidates its queued entry and the callback never runs. A `Ticker` re-arms by **whole periods** past a late firing (`next = when + period*(1 + delay/period)`), which keeps the tick *phase* aligned to the original schedule instead of drifting; combined with `sendTime`'s non-blocking send onto the cap-1 channel, a receiver too slow to keep up therefore **loses** ticks rather than seeing them queue — Go's documented "adjust the time interval or drop ticks to make up for slow receivers". **ONE firing per timer per pass — the service pass reads the clock exactly once.** `serviceTimers` samples `now` once and threads that single value through the whole drain. That is the invariant, not an optimization, and Go does the same for the same reason: the scheduler samples the clock in `timers.check` and hands it down through `timers.run(now)` to `timer.unlockAndRun(now)`, never re-reading it inside a pass. The consequence is a theorem rather than a heuristic — *within one service pass every timer fires at most once*. A one-shot leaves the heap with `when` cleared; a periodic timer is re-armed to `next = when + period*(1 + delay/period)` with `delay = now - when >= 0`, and writing `delay = q*period + r` for `0 <= r < period` gives `next = when + period*(1 + q) = now + (period - r)`, which `r < period` makes **strictly greater than `now`** — so the re-peek always takes the "not yet due" branch and the drain ends. It holds for every period, down to 1 ns. Re-reading the clock per iteration broke the theorem and was a real defect (recorded r36, fixed r39): the advanced `when` lands one nanosecond ahead, a freshly read `now` has already passed it, and the same ticker fires again — for as long as consecutive reads of the ~100 ns monotonic source keep advancing. The burst is invisible while nobody is receiving (the non-blocking send onto a cap-1 channel drops all but one) but `time`'s own `TestChan` *is* receiving: the two stale values an async ticker is allowed became three or more, and `noTick` reported "extra tick" in **all three** `asynctimerchan` modes — which the then-standing asynchronous-timer-channel divergence (now implemented, below), scoped to the sync mode, never explained. The invariant does not rate-limit: a pass fires each due timer once and then waits until the new head deadline, which for a fast ticker is already past, so the wait returns at once and the next pass fires it again — exactly as Go's scheduler calls `check` again with a fresh `now`. The bound is on re-firing *within* a pass, which is what "drop ticks to make up for slow receivers" means. Nor can it delay anything: `next` depends on `now` only through the non-decreasing floor `delay/period`, so hoisting the read can only make the pass's `deadline` smaller or equal, and `waitUntil` recomputes `remaining` from a fresh clock — no timer can wake later than the per-iteration version would have woken it, and when `delay < period` (the common case) the deadline is identical either way. A timer coming due *during* a pass waits for the next one and that is not a delay either, because the head of the heap is the minimum `when` among live entries, so the deadline is already past by the end of the drain and the next pass starts at once. Two adjacent places are deliberately **not** Go, and the fidelity claim should not be read past them: Go's `check` releases the timer-set lock around *each* callback and re-validates the head between them, so a `Stop` landing mid-pass cancels the callbacks after it, where this drain commits the whole batch under one lock hold and then runs it; and Go keeps one heap entry per *timer* (repositioned in place, zombies swept) where this keeps one per *arm*, reclaiming a dead entry only when it reaches the head. Both predate the single clock sample and are narrowed by it, since a frozen `now` commits a smaller batch. A standing constraint follows from the same arithmetic: at the instant a ticker is stopped or reset at most **two** of its ticks can exist — one buffered, one committed but not yet sent. In *synchronous* mode both are now revoked outright (the drain takes the first, the `seq` check the second — see the next entry), so the guarantee no longer rests on that count; in `asynctimerchan={1,2}` it still does, and two is exactly what `drainAsync` drains, so the margin there is zero and any later change that lets two ticks for one timer be committed before their callbacks run re-breaks `noTick` in those modes without touching `time_impl.cs`. **`tick.cs` is hand-owned, and revertibly so.** Go builds a `Ticker` by reinterpreting the `*Timer` the runtime returned — `(*Ticker)(unsafe.Pointer(newTimer(…)))`, plus the mirror-image casts in `Ticker.Stop`/`Reset` — because "Ticker and Timer have the same layout". Converted literally those three reinterprets **compile but cannot work**: each is a managed-box `uintptr` round-trip whose address escapes its `fixed` pin, and nothing references the `ж` `newTimer` produced, so the ticker's storage is collected at the next GC (the retained-pointer worst case of the corpus-wide hazard in `docs/phase4/FINDING-managed-box-uintptr-lifetime.md`). Pinning is not available either — `Ticker` holds a `channel