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/typestoolchain, undersrc/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 oldgocorelibrary have been updated to reflect this. See also:Architecture.md,Glossary.md,Roadmap.md, andCLAUDE.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
- Package-Level Variable Initialization Order
- Compiled Library versus Source Code
- Constant Values
- Native and Narrow Integer Types
- Named Numeric Types and Constant Contexts
- Floating-Point Formatting
- Nil and Zero Values
- Empty Interface (
any) - Multi-Assignment and Evaluation Order
- Short Variable Redeclaration (Shadowing)
- Multi-Result Values and Comma-Ok Forms
- Slices and Arrays
- Strings (
@stringandsstring) - Maps and Channels
- Generic Constraints
- Type Aliasing
- Delegates to Value Receiver Instances
- Defer / Panic / Recover
- Expression Switch Statements
- Type Switch Statements
- Struct Types
- Struct Type Embedding
- Interfaces
- Pointers
- Implicit Pointer Dereferencing
- Labeled Control Flow and Loop Variables
- The
go.golibsupport namespace - Source Generators
- Manually-Converted Declarations
- Comments
- Deterministic Output
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/atomic → go.@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:
-
Duplicate project names.
cloud.google.com/go/bigquery’sdatatransfer/apiv1andstorage/apiv1both emittedapiv1.csproj. Visual Studio refuses to open a solution containing two projects of the same name and reports no reason — silently doing nothing from a file dialog, offering to forget the entry from the recent list — so the entire generated.slnxfailed to load (issue #35). One reported-recurseconversion had 175 such projects across 49 colliding names out of 1,727. -
A collapsed namespace. The truncated arm joins its segments with
.before the separator split that builds the namespace, so the qualification never reached it: a truncatedinternal/errorslanded ongo.errors_package— the converted standard library’s own class.
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:
- .NET’s built-in
UseArtifactsOutputwas evaluated first and does not fit — it lays artifacts out under$(ArtifactsPath)\obj\$(MSBuildProjectName)\, and the project name is the long thing here. Measured at a 13-character output root, the deepest package still landed a 309-character artifact. -
CompilerGeneratedFilesOutputPathis set from the.targets, not the.props— the generated.csprojsets it in the project body, which beats any.propsvalue — and with no trailing separator, because csc receives it as/generatedfilesout:"<path>"where a trailing backslash escapes the closing quote and the compiler is handed the directory as a file name (CS2021). Keeping this out of the csproj template is also what holds the standard library and behavioral corpus at zero movement.
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:
- When the output root IS the runtime root (the one-positional form), the pin stays relative —
$(MSBuildThisFileDirectory), the deploy-core form — so the tree can move as a unit. An isolated output root pins the absolute resolved root (forward slashes and a trailing separator, per the section below); a re-conversion refreshes it. - The
Conditionkeeps it a default: ago2csPathenvironment variable, a-p:go2csPathbuild global, or a higherDirectory.Build.propsstill wins. -
-recurse=nugetdeliberately emits no pin — it has no$(go2csPath)references to resolve, and its props defaultsGoStdLibVersioninstead. A foreignDirectory.Build.propsat the output root (deploy-core’s, or user-authored) is never clobbered, per the generated-file marker rule.
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:
-
The converter’s own path arithmetic. The reference used to be composed by hand — replace every
/with\, thenfilepath.Joina backslash-prefixed file name. On Windowsfilepath.Cleanfolded that back into a well-formed path; on Unixfilepath.Jointreats\as an ordinary filename character, so a Linux-hosted conversion emitted the malformed$(go2csPath)core\fmt/\fmt.csprojfor every stdlib reference in every project — silent at emission, a restore failure later (F5,PLAN-linux-operation.md§A1.1). The composition is nowemittedProjectReference(importOperations.go):path.Join(slash-only, host-independent) over afilepath.ToSlash‘d directory.writeProjectFileandwriteTestProjectadditionallyToSlashat the emission point, because a sibling reference made relative byfilepath.Relarrives OS-native. -
The harnesses that READ an emitted reference.
BehavioralRunner.PreBuildSharedDepsandPerformanceRunnerparseProjectReference Include="…"out of the csproj and resolve it withPath.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:
- It keys the embedded standard-library metadata, which records the vendored spelling (
##vendor.golang.org.x.crypto.chacha20), so the unvendored name matched no section at all (asserted directly by the guard). Wherever that record is the source — a dependency with nopackage_info.cson disk, i.e. a-recurse=nugetreference — the package’s exported aliases andGoImplementrecords would have come back empty and silently fallen through to the derive-from-declarations path. - It composes the imported-alias class path. The unvendored form yields
go.golang.org.x.crypto.chacha20_package, which names a class that exists nowhere — the CS0234 family the namespace arm above exists to prevent. It was latent rather than active only becauseloadImportedTypeAliasesdedupes on the dependency’spackage_info.cspath, which is the same file for both spellings, so whichever spelling was resolved first won and the other never applied its aliases.
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:
-
-testshost output —test-csproj-template.xmlsetsBaseOutputPath=bin/tests/(alongsideobj/tests/) precisely so the test project and the production project that shares its directory do not collide. Theobj/half worked; thebin/half never did, so every converted test host was writing into its production package’s output tree. -
The Native AOT perf publishes —
src/tests/Performance/Directory.Build.propssetsBaseOutputPath=bin\aot-build\under itsPerfAotgate. With the pin, the AOT publish’s build step wrote throughOutDirinto the JIT tree and overwrote the JIT binary with a self-contained,IsDynamicCodeSupported=falseone — which the runner then measured and published as the “JIT” column. Seedocs/phase4/DESIGN-iface-shell-caching.md§10. -
End-user layouts — any converted project consumed under an artifacts/CI convention that sets
BaseOutputPathwas ignored the same way.
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 barlib → go.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 CrossPkgLibꓸTemperature = 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ꓸError → runtime_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 cycle — runtime 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:
-
Recognition —
constraint.IsGoBuild/constraint.IsPlusBuilddecide what is a constraint line. Both require the comment at column zero, and only the file header is scanned (everything above the package clause, line comments only). The regex that preceded them matched any//go:-prefixed line anywhere in the file, so a//go:buildquoted in documentation below the package clause gated the file it was describing. -
Precedence — a
//go:buildline wins outright; the legacy// +buildlines apply only in its absence, ANDed across lines, with,meaning AND and a space meaning OR inside one. The old regex could not see// +buildat all (no//go:prefix), so a legacy-only file — the norm in third-party modules predating Go 1.17, exactly what-recursemeets — converted as unconstrained. -
Evaluation —
Expr.Evaldrives onematchTagcallback, so the boolean structure (&&,||,!, parentheses) is the stdlib’s problem and go2cs owns only “is this one tag satisfied”. Tags resolve in three layers: theallowedPlatformsmap (GOOS, GOARCH, the derivedunix/posix, the compiler tags, and every-tagsvalue), then the Go release tags, thenbuild.Default.ToolTagsfor dotted tags. Matching is case-sensitive, as the toolchain matches; the old evaluator lowercased the whole expression first, which quietly made a mixed-case-tags MyTagunsatisfiable —SetTagstored it verbatim but the lookup folded it.
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.1…go1.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 lazy — matchTag 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 using — using _ = <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ᴛᴛblankImportꓸBlankImportSideEffectsꓸjpeglike() {
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/png →
initᴛᴛ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:
- its
<ImportedTypeAliases>block loses the dependency’s exported aliases (global using syscallꓸHandle = go.syscall_package.ΔHandle,netꓸAddr,timeꓸLocation, …); and - not knowing that
syscall’s assembly already implementserroronsyscall.Errno, it re-declares the pair locally —[assembly: GoImplement<syscall_package.Errno, error>]— and wraps the value at the cast site in a locally-generated adapter (new syscall_Errnoᴠerror(e)) instead of converting implicitly.
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.PublishedStdLib — isStdLib && 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 PathError→error pointer adapter, sort’s
IntSlice→Interface value implement, and syscall.Errno), leaving only the one record the
consumer legitimately owns (os.File → io.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 Paletted→image.Image record
must not satisfy a Paletted→draw.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/parse→Node 33, go/types→Object/ΔType 20, image→Image 4,
net/http→RoundTripper/ΔHandler 4, net/url→error 2, net/textproto→error 1,
go/internal/srcimporter→types.Importer 1, go/build/constraint→Expr 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.Palette→color.Model (5) and encoding/binary’s
bigEndian/littleEndian→ByteOrder (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 converted —
convertToInterfaceType 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 interface is EXPORTED. A record is a CROSS-ASSEMBLY contract — it exists so another assembly’s cast can drop its local adapter — and no other assembly can name an unexported interface, so a record for one could never be consulted. The package’s own casts already record what it needs internally.
-
The target’s underlying is NOT a
*types.Signature. A named FUNC type is a C# delegate, which cannot be a partial struct, soImplementGeneratoremits an adapter CLASS for it; a consumer trusting THAT record hands a bare delegate to an interface slot (CS0029 — net/http’sHandlerFunc→ΔHandler). This is the declaring-side half ofvalueRecordRealizesAsPartialStructabove. -
Neither side is GENERIC. A type argument cannot appear in an assembly-attribute type argument
(CS0246) — the same exclusion
convertToInterfaceType’stargetIsOpenGenericmakes. - Both sides are declared in a file this run CONVERTS. A package scope holds every file’s declarations, including build-constraint-excluded ones, and a record naming a type no emitted file declares is CS0246.
-
Every interface method is REALIZABLE by the generator — it resolves on the type itself or
through at most ONE embedded field (
types.LookupFieldOrMethodindex length ≤ 2).ImplementGeneratorforwards a promoted member through a single embed hop and says so (“Go’s promotion ambiguity rules make multi-embed satisfaction rare; extend when needed”), so a deeper promotion emits a forwarder through the WRONG hop:CrossPkgUser’srigembedsCrossPkgLib.Device, which embedsSensor, whereLabellives, andCrossPkgLib_package.Label(this.Device)is CS1503 for want ofthis.Device.Sensor. Promotion through an embedded INTERFACE is the common shape this still admits (sort’sreverseembedsInterface;debug/macho’s segment types embedLoadBytes). The bound is deliberately CONSERVATIVE rather than a model of the generator’s exact reach — it costs exactly two stdlib records (net’stcpConnWithoutReadFrom/tcpConnWithoutWriteTo→Conn, whose*TCPConnhop the generator’sembedHopDeepPathsarm can in fact follow), neither of which has a consumer, and withholding a speculative record is always safe: the consumer keeps the adapter it had before.
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 textValue→Value under
Getter, and net/runtime’s errorString→error 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 reverse→Interface, image’s Rectangle→RGBA64Image, debug/macho’s five Load
implementers, io’s discard→StringWriter), 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:
-
BOTH sides are EXPORTED (
pointerRecordIsPubliclyRealizable).ImplementGeneratorscopes the adapter classpubliconly when the struct and the interface are each public,internalotherwise. A record is a cross-assembly contract, and this form’s contract is “this class exists and you may name it” — so a record naming an unexported participant advertises a class no consumer can reference (CS0122), an existence signal that is a lie. The value form needs only the interface gate because its contract is realized by a conversion that names nothing, which is why the two rules differ here and only here.
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.Mutex→Locker,
image/color.RGBA64→Color and parse.BranchNode→Node. 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:
- every converted stdlib project it emitted (
core/<pkg>/<projectName>.csproj, grouped under a/core/folder), - any per-package test projects (
*_test.csproj, grouped under a/tests/folder — inert until Phase 4 emits them, and the folder is omitted entirely when there are none), - the shared
golibruntime (core/golib/golib.csproj), and - the
go2cs-gensource-generator/analyzer project (gen/go2cs-gen/go2cs-gen.csproj, under a/generators/folder).
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:
-
/src/— the project(s) being converted (the app’s own main-module packages), -
/pkg/— their converted dependency packages (module-cache orreplacethird-party), then -
/core/— the go2cs runtime/generator projects (golib,go2cs-gen).
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):
-
Cross-file: syscall’s
var procSetFilePointerEx = modkernel32.NewProc("SetFilePointerEx")(syscall_windows.go) readsmodkernel32declared 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 importingos). -
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’sprocGetStdHandle. 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. -
Same-file forward reference:
var first = base + 1declared abovevar base = 41— C# readsbase’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 initᴛprocSetFilePointerEx() { procSetFilePointerEx = modkernel32.NewProc("SetFilePointerEx"u8); }
// package_init.cs (generated)
partial class syscall_package {
static syscall_package() {
initᴛprocSetFilePointerEx();
// … 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 initᴛsingle() { 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 initᴛcwd() { var tupleᴛ1ʗ = fakeGetwd(); cwd = tupleᴛ1ʗ.Item1; cwdErr = tupleᴛ1ʗ.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:
-
External variant (
<pkg>_test— its own<pkg>_test_packageclass): a generatedpackage_init_external_test.cssupplies the class’s static constructor directly, exactly likepackage_init.cs. -
Internal variant (test files join the production
<pkg>_packageclass): when the productionpackage_init.csexists it already owns the single static-ctor slot, so under-testsits constructor ends with a call to an erasable classic partial method —static partial void initᴛᴛtests();— and the test conversion emitspackage_init_internal_test.csimplementing it with the test-side relocations. Unimplemented (the production compile set excludes*_test.cs), C# erases both the declaration and the call, so the production assembly is untouched at runtime. Test relocations therefore run after every production relocation — semantically safe, since a production initializer can never depend on a test var. When the production package relocated nothing, the internal test variant claims the static ctor itself.
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 initᴛpipeLabel() { 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:
- Function-local only (package-level constants keep the wrapper: they are visible across functions whose contexts may differ, including from other files of the package).
-
Untyped integer/rune/float/COMPLEX, excluding any value on the
GoBigConst(BigInteger) path. (Untyped complex was excluded whilecomplex128had no non-constemission; a representable complex const now emitsstatic readonlylikeuintptr, and the wrapper it replaces is actively wrong for it — see A complex constant emits a complex VALUE below. A complex use type tightens only an untyped-COMPLEX const: the converter never widens an untyped integer const tocomplex128because one use happens to be complex-typed.) -
Every use must record a concrete numeric basic type in go/types’
Info.Types, and all uses must agree on ONE basic kind. go/types records the implicit-conversion target for an untyped operand (float64forC + xwithx float64), so a use that stays untyped (another constant’s initializer), resolves to a NAMED type or type parameter, or records a non-numeric type (string(c)) disqualifies — as do mixed concrete types (float64andfloat32uses of one constant). -
No use may participate in constant folding: an ancestor expression carrying a folded constant value (
uint64(B1) << 32— cbrt’s B1/B2 stayUntypedInt) disqualifies. Go folds untyped constant expressions at arbitrary precision; re-expressing an operand at a concrete C# type could change the folded result (or re-fold it in C#’s checked int32 arithmetic). - The exact value is re-checked representable in the resolved type (belt-and-braces — go/types already validated each use’s conversion).
A tightened constant composes with the exact-float emission above (the cbrt literals round-trip to their documented bit patterns, e.g. C ↔ 0x3FE15F15F15F15F1), 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 (double→float on arg 1, Complex→float 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 - 1 → 9223372036854775807L).
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.
-
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 (
updateExprTypestops descending once the node it is retyping is itself constant), so[]int32{-(1<<31 - 1)}recordsuntyped inton the inner1<<31 - 1andint32only 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’satoi_testparseInt32 table —{"-2147483647", -(1<<31 - 1), nil}against anint32struct field — is exactly this (CS1503).widenedConstExprCastTypenow accepts a unary root too andconvUnaryExprapplies the cast at its own emission, mirroringconvBinaryExpr:(int32)(-(2147483648L - 1)).^takes the same treatment ((int32)(~(2147483648L - 1))) and aninttarget narrows tonint; the non-constant unary operators (&x,<-ch,!b) are excluded by the existing constant-value and integer-kind guards. -
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
longwhen one of its operands does: in[]int32{(1<<31 - 1) - 1}the root’s operands are(1<<31 - 1)(value in range, unfolded) and1, and only the grandchild shift folds — likewise[]int32{1<<40>>20 - 1}.operandRendersWidenedFoldnow 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 int → nint and Go uint → nuint; 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 wrapping — var 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 (ulong→nuint 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 supportnint, but implicit index support (theIndex/Rangesyntax) currently only works withint, so range-operation indices are cast tointwhere needed. (The earlier strategy of compiling tolong/ulong, or of custom@int/@uintstructs selected by aTARGET32BITdirective, has been superseded bynint/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 type — operator ++(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 numeric — uint64(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/TextOff → uint64/uintptr, taggedPointer/traceTime → int64, 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:
-
Basic-target conversions omit the outer wrap. A conversion whose target is a basic C# type —
uint64(a),int(k),float64(b), and evenunsafe.Pointer(p)(go/types models it as a*types.Basic) — emits(uint64)a, not((uint64)a). The result of a basic-typed cast can never be the receiver of a postfix./[]/invocation (Go basic types expose no callable members and the converter emits none on them), and the C# cast operator outranks every binary operator, so the bare form binds correctly in any surrounding context:f((uint64)a),return (uint64)a;,(uint64)a << n,(nint)(uint8)k < len(s), and(nint)x.Load() + 5(which parses as((nint)(x.Load())) + 5— postfix.binds before the cast). A named-type target keeps the defensive outer parens((Named)x)— its result can be member-accessed (Named(x).Method()), which is parent-context-dependent and not decidable at the conversion site.stringis the exception among basic types: its C# representation is the member-accessible golib@stringstruct, so astring(x)conversion is a valid postfix receiver — the variadic-string spreadstring(r)...→((@string)(rune)r).ꓸꓸꓸ, an indexstring(b)[i]→((@string)b)[i], orlen(string(b)). Dropping the wrap there reparses the postfix against the cast’s inner operand ((@string)(rune)r.ꓸꓸꓸbinds.ꓸꓸꓸtor, CS1061), so astringtarget retains the outer parens like a named type. (unsafe.Pointerstays in the no-wrap set: although its C#@unsafe.Pointeris a struct, Go exposes no members onunsafe.Pointerand the converter never emits a postfix on such a conversion result.) -
Identity conversions are not double-cast — EXCEPT a plain constant argument.
arenaIdx(x)wherexis alreadyarenaIdxis a Go no-op. This arises for an untyped-constant shift that adopts the target type from context (arenaIdx(1 << bits), whose operand go/types already types asarenaIdx, so the inner conversion has already emitted(arenaIdx)((nuint)1 << bits)), and for a plainarenaIdx(yArenaIdx). Wrapping the already-typed expression in a second(arenaIdx)cast just doubles it, so the converted argument is returned as-is. The exception: a plain constant argument (Word(1)) — go/types types the constant AS the target (identity), but the render is the bare literal, which under a binary operator resolves asintand degrades the whole expression (math/big’smask := Word(1)<<s - 1, CS0029). The named cast is re-imposed at the conversion site —((Word)1) << (int)(s)) - 1— which also made the older:=-declaration-only patch in visitAssignStmt redundant (it now sees the cast already present).
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 — shuffledFS→MapFS→map, 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 againstbuiltin.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
uint32→nint 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:
- A direct cast to the foreign named type has no route.
(NameOff)src.Valuewheresrc.ValueisulongandNameOff(internal/abi) is a different assembly is CS0030 — C# does not select the foreign type’sint32-based user conversion for aulongsource across the assembly boundary (the same cast to a local named type compiles). It must go through the foreign type’s underlying basic:new …NameOff((int)src.Value). - The default host can be a phantom. The operator is hosted in
partial struct {sourceType}; if that source is the foreign type (reached here via a local alias, e.g. runtime’sglobal using nameOff = abi.NameOff, so the cross-package dot is hidden and the conversion records asInverted), thepartial struct NameOffdeclares a new empty local type rather than extending the foreign one — CS1729 (no constructor). The operator is relocated into the local type instead.
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 ulong→long 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 timestamp→Time 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 long→nint 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 long — maxswap: 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 answers — runtime/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 struct — type 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.go — type index Index, where Index has an ints-typed field sa with len/get methods — and both fixed generally:
-
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 emitsthis 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 = vstill 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. -
A metadata-only underlying resolved to nothing.
GetStructDeclarationcan only see a struct whose SOURCE is in this compilation or in aCompilationReference; 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.FindUnderlyingStructSymbolnow resolves the[GoType("…")]definition to itsINamedTypeSymbolwhen the syntax walk misses — trying the name as written (global::go.index.suffixarray_package.Index, the fully-rooted form the-testswhite-box bridge emits) and thengo.-rooted (time_package.Duration, the package-alias-qualified form ordinary cross-package emission uses, is not a CLR name) — andGetForeignStructMembersenumerates it. Membership mirrorsStructTypeTemplate’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 byCompilation.IsSymbolAccessibleWithinrather than a public-only test, which is Go’s own rule projected into C#: an exported field ispublicand always forwards, while an unexported one isinternaland 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 spread — append(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 @string — append(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 &^ 15 → nuint & ~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 BigInteger→double 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 writeBits’ bits |= 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:
-
%gpromotes a zero precision to one significant digit — and that promotion feeds the exponent-form decision too. strconv mutatesprecitself (case 'g', 'G': if prec == 0 { prec = 1 }), not merely the digit count, so%.0gof0.7is0.7. Applying the promotion only to the digit count leaves the threshold test comparing against 0, which rounds the value away to0. -
±Inf carries an inherent sign (
+Inf, never∞), the space flag demotes its+to a space, and Inf/NaN space-pad under the0flag because they do not look like numbers.
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 reflectliteꓸType 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):
-
Pointer identity: a null reference and a nil box are the same Go nil pointer and compare
equal; a heap box holding a nil value (
ж<ж<T>>captured local) is a non-nil pointer holding nil — two such distinct addresses are unequal and neither equals nil (&p1 != &p2thoughp1 == p2 == nil), including when both slots share the canonical singleton. -
Interface adapters null-coalesce their receiver box to the canonical instance, so an
interface holding a nil
*Tkeeps its dynamic type, typed nils compare correctly across theany/interface boundary ((*A)(nil)-error ≠(*B)(nil)-error), andcase *T:matches with a nil pointee. - The non-generic
INilPointersurface exposes the structural predicate to runtime machinery holding a pointer only asobject(equality tails, the reflection bridge’sIsNil/Elem).
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:
-
Pointer kinds only. An interface- or func-typed slot holding
nullis the nil interface / nil func, and Go packs that as the nil eface. Re-encoding those would invert the bug. -
No new representation. The value handed out is
ж<T>.NilBox— the same singletonreflect.Zeroof a pointer kind already yields (GoReflect.ZeroValueOf) and every emittednil→*Tconversion already mints. So the packed value compares equal to a language-level(*T)(nil)and asserts through the ordinary witness machinery. A slot whose static type resolves to no canonical nil keeps itsnull, so the rule can only add type information, never substitute a wrong one.
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 bare
nullboxes as nothing at all — the type is gone,x == nilanswers Go’sfalsewithtrue, the assert fails, and the reflection bridge finds no descriptor; and - a deref-aliased pointer (
ref var p = ref Ꮡp.DerefOrNull()) rendered by its value alias boxes a copy of the pointee — dynamic typeTwhere Go says*T, pointer identity gone, and a nil one panics at the box instead of crossing intact.
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/types’ operand.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:
- A bare int literal (or literal-only arithmetic) in the int32 range:
convBasicLitrenders it as a plain C# integer literal, which isSystem.Int32. (An int constant outside int32 range already renders(nint)…L, and a rune literal renders(rune)'A'/ a float literal2.5D— all already the default CLR type, so those need nothing.) - Any expression referencing a named untyped constant (
const fsize = 5), whichvisitValueSpecemits as a golibUntypedInt/UntypedFloatwrapper struct, never a CLR number —fsize + 1evaluates through the wrapper’s operator overloads and boxes the STRUCT, which matches no Go type at all. This holds at every magnitude and for every wrapped kind, which is why the int32-range test applies to the literal rendering only.
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/
UntypedFloat — info.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 type-parameter parameter constrained by
any(func f[T any](v T)) reads as an empty interface here too, but its instantiation binds the argument to the concreteT(int → thenintparameter), where a bare int literal already converts implicitly. Every gate usesisEmptyInterfaceTarget(which excludes type parameters), unlike the u8-span→@stringcase, where aK=stringparameter genuinely needs the cast to bind. - An untyped COMPLEX constant is out of scope: a named one renders as golib
GoBigConst(aBigInteger.Parseof the literal text —visitValueSpec’swriteUntypedConstpath, with its own standing TODO), a separate pre-existing gap that a(complex128)cast would not close. A complex literal renders1D + 2D.i()and already boxes ascomplex128. - Untyped bool constants need nothing:
true/falserender as C#bool, already the Go type. Untyped string constants DO need the cast, but under the mirror-image rule — see below. - A cast onto an expression that already renders at the default type — a typed constant
(
const seqFirst int = iota→const nint), or a local untyped const thatvisitValueSpectightened to a concrete C# type (const float64 derived = 7) — is a harmless no-op the predicate does not attempt to suppress. It cannot know the declaration’s chosen C# spelling from the argument alone, and an extra cast is cosmetic noise where a missing one is a runtime divergence.
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 redeclaration — a, 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 ¶m 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 ¶m.field form renders through the box
accessor (Ꮡb.of(Box.ᏑR).of(Rect.ᏑMin)) and ¶m[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 ¶m.field bump, an ¶m[i] bump on an array parameter, and
three controls that must not change: a read-only ¶m, 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 files — encoding/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 variable — f := 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 (x → xΔ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 initializer — signame := 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 = nil — go/types’ generate_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 qualified — builtin.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 ΔLabel — equal 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 convertToCSFullTypeName→getAliasedTypeName 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 CollisionFieldBoxAccessor — capturedLocalNamedAfterType, 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 CollisionFieldBoxAccessor — localShadowsCollisionType, 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 (chacha20poly1305 → crypto/tls → net/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 p → ref 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 (i → iΔ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:
-
A test-file METHOD over a production element’s name (B2). strings’ export_test.go declares
func (r *Replacer) Replacer() any— the ordinary type-vs-method resolution (above) would Δ-rename the type, but the productionreplace.cson disk keepsReplacer, so the assembly split into two disagreeing halves (CS0102strings_packagealready containsReplacer+ CS0246ΔReplacer). When the colliding method declarators are all test-declared and the element is production-declared, the element keeps its bare name (and no exported alias is registered) and the method Δ-renames instead; a FuncDecl colliding with a same-package element is necessarily a method (Go keeps method names in a separate namespace — any other same-scope reuse is a Go compile error). When a production method also carries the name, the production universe had the same collision and already renamed the element on disk, so the normal path stays consistent. -
A test-file METHOD shadowing a dot-imported function the variant calls unqualified (B9). Go keeps method names and dot-imported function names in separate namespaces, but both land in the C# package class’s member-lookup scope, and an enclosing class’s method group always wins over
using staticimports — sort_test.go’s dot-importedSort(data)bound example_keys_test.go’sBy.Sortextension (CS1501 ×14, plus 5 downstream method-group CS1503s the wave-2 probe had attributed to B10). A test-declared method whose name matches a foreign function the variant references unqualified (only unqualified sites conflict — SelectorExpr Sels are excluded, so a qualifiedsort.Sort(ps)never triggers; an unqualified foreign-function reference can only come from a dot-import) Δ-renames, and the dot-imported call keeps its bare emission, now binding throughusing static. -
A test-file FREE FUNCTION whose emitted signature matches a production METHOD’s receiver (2026-07-20). A method emits as a C# extension method, so its receiver becomes the leading
thisparameter — andthisdoes not participate in C# signature identity. math/big’sfunc (z nat) norm() nat(nat.go) andfunc norm(x nat) nat(int_test.go) are legal Go in separate namespaces, but both emit asnorm(nat)inbig_package: CS0111.resolveReceiverParameterCollisionscompares each same-named pair’s emitted parameter list — the method’s receiver type followed by its parameters, against the free function’s parameters — and Δ-renames the test-side declarator. Discrimination is exact: an extra parameter (func trim(x nat, n int)) or a different first parameter (func keep(n int)) emits distinctly and keeps its plain name, as do generic declarations (type parameters keep the C# signatures distinct) and a variadic/non-variadic mismatch. Two methods can never collide this way (Go forbids redeclaring one method on one type) and two free functions cannot share a package scope, so a method/free-function pair is the only shape. When both sides are test-declared the FREE FUNCTION is the one renamed, so the outcome does not depend on declaration order and two colliding declarators never both becomeΔ-prefixed; a collision between two production declarators is deliberately left alone, since it would equally break the production-only conversion and is a different fix than test-variant coherence (the 302-package corpus compiles clean, so no instance exists).
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):
-
reference— a black-box-only suite references the colocated production project and emits only the external test package into the test assembly. -
whitebox-reference— a suite with an internal variant still references production, but emits the internal_test.godeclarations into a separate friend-assembly bridge. This keeps the production assembly as the sole identity while preserving access to Go-unexported members. -
recompile— the original same-assembly shape, retained only as the deterministic fallback when test-contributed metadata genuinely has to mutate a closed production type.
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:
- The normal production scan already reads build-selected same-package
_test.gofiles to stabilize alias-shadow spelling. That same cheap scan now reports whether an internal test file exists. Only then does the production.csprojemit<InternalsVisibleTo Include="$(AssemblyName).tests" />; packages with no internal tests remain byte-stable. - Internal test files emit into
<name>_internal_test_package, withusing static <namespace>.<name>_package. Production declarations remain untouched. Test-host registrations retain the Go package name in the manifest but target this bridge class. -
go/packagesloads production, internal and external test variants together. An external test reference is routed to the bridge only when itsgo/types.Objectbelongs 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 howio_testreachesErrInvalidWritefromexport_test.gowithout source rewriting or a generated alias contract. - 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 inpackage_info_internal_test.cs, whose first — and only — class is the bridge (also the bridge’s singlestaticdeclaration and its[GoPackage]carrier); every other record — production-qualified, foreign, or external-declared — stays inpackage_test_info.csunder 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 (anchoredAdapterMemberName—adapterStructKeynormalizes 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 emittedParseErrorжerrorwhere the generator wrotecsv_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 (ΔBufferfor embeddedbytes.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). - 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.csis therefore written UNCONDITIONALLY, records or not (2026-08-14). The file is not only a metadata anchor: it is the bridge class’s ONLYpublic static partialdeclaration. 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; seepackage_info.cs’sTypeAccessibilitysection for the same division of labour applied to types. Writing the unit only when the variant contributed bridge-anchoredGoImplement/GoImplicitConvrecords therefore left a record-less bridge with nostaticdeclaration 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) errorin itsexport_test.go. Mixed suites that appear to escape it do so incidentally:sort,bytesandstringseach happen to have a go2cs-genRecvGeneratorfile that re-declares the classpublic 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 (residualsTestValues, a raw-address array reinterpret materializing a zero-lengtharray<T>, andTestGetMUIStringValue); guarded byTestWhiteboxBridgeUnitIsWrittenWithoutBridgeRecords. - The friend grant is inserted after template rendering, never as a template verb: a user-supplied
-csprojtemplate keeps its historical verb count and renders exactly as before (insertFriendAssemblyAccess, anchored on the first closingPropertyGroup). 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 bridge member HIDES the production
using static. The bridge binds production throughusing static <pkg>_package, and C# member lookup stops at the first enclosing type carrying the name — so any member the bridge declares hides every same-named production member, overload resolution included. container/heap’sfunc (h *myHeap) Pop() anyhidheap_package.Pop(Interface), and the suite’s ownPop(h)/Push(h, i)bound the extension by value (CS1620 ×8). Such a reference is emitted production-class-qualified (go.container.heap_package.Pop(…)), the remedypackageBuiltinShadowsalready applies to a shadowedusing static go.builtin. The shadow set is the internal variant’s package-level declarations plus its methods — those emit as static extension members of the same class — keyed on raw Go names, and consulted only for a production-declared package-level object. -
A production type is FOREIGN for a VALUE implement. “Local” is what selects go2cs-gen’s partial-struct realization, which folds the interface into the type’s own declaration; a closed referenced type instead gets a per-interface
ᴠVALUE ADAPTER. Both consequences matter and only together: the cast site must CONSTRUCT the adapter, and the record must be EXEMPT from the interface-inheritance prune (sound only for the one-type-one-interface-list partial shape). encoding/binary’sTestByteOrdercastsBigEndianto a function-localbyteOrderthat EMBEDS the productionByteOrder, so the subsumedbigEndian → ByteOrderpair was pruned as covered — true while a merged partial carried it, false against a referenced assembly, and everyRead(r, BigEndian, data)was CS1503. The arm mirrors the existing both-foreign one,importedValueImplementscheck included, so a pair production already records stays a bare implicit conversion. -
A value adapter must name the anchor its record lands in. A mixed white-box suite has two metadata anchors, and the value-adapter reference qualified through the external test class unconditionally — right for a production↔production pair, wrong for one whose BRIDGE-declared interface anchors it at the bridge (CS0426). The reference now applies
splitWhiteboxVariantRecords’ own bridge-declared-name predicate to the record’s two participants, folding in the live LIFTED claims while the bridge is the variant under conversion. (The pointer form already reached the same answer through its deferred marker’semittedAdapterPairAnchors; a value adapter’s name is composed inline and has no marker to resolve.) -
A bridge-declared LIFTED name must reach the record split. That split keys on the record’s EMITTED name against a set built from go/types
TypeNamedefs, which carry the GO-SOURCE name — so encoding/hex’stype r struct{ io.Reader }insideTestEncoderDecoderwas collected asrwhile its record namedTestEncoderDecoder_r. The record anchored in the external class and the generator declared a PHANTOM empty type there; every symptom followed from that one type being empty (CS0103 on the promoted embed it does not declare, CS0034 because a phantom carries no[GoType]and so no TypeGenerator(T,T)==to bind exactly, CS1503 at the cast site). The internal variant’s live lift claims are unioned in right after it converts, while they still stand. -
A BARE record name resolves in the variant that RECORDED it, never across variants. The bridge’s declared-name set is a set of SIMPLE names, and the two
-testsvariants are separate Go packages, free to declare the same one: encoding/gob declaresPointincodec_test.go(package gob) and again inexample_interface_test.go(package gob_test— the one whoseHypotenuseimplementsPythagoras). Each variant’s records are split as that variant converts, and every cross-variant reference is routed bygo/types.Objectidentity to a CLASS-QUALIFIED spelling —whiteboxBridgeNamedTyperenders an internal-test type the external suite names asglobal::<ns>.<pkg>_internal_test_package.T, andwhiteboxProductionObjectdoes the mirror while the bridge converts — so a bare name recorded by the external suite is external-declared by construction, whatever the bridge spells the same way. Matching it against the bridge’s set regardless anchored the EXTERNAL pairPoint → Pythagorasinpackage_info_internal_test.cs, wherePythagorasis not in scope:CS0246, no test host built, and all 106 of gob’s verdicts read empty — a missing host masquerading as mass runtime failure. The set is therefore consulted only while splitting the BRIDGE variant’s own records (splitWhiteboxVariantRecords’bridgeVariant), and the emission mirror that names an adapter through the anchor its record will land in carries the identical gate (whiteboxBridgeDeclaredType), so the two cannot disagree about a pair. Write-time qualification is not a substitute:qualifyAmbiguousTestTypeRefsroots an ambiguous bare name at the file it is ALREADY being written into, so a mis-anchored record comes out merely qualified to the wrong variant’s class (gob_internal_test_package.Point). Guarded byTestSplitWhiteboxVariantRecordsResolvesBareNamesInTheRecordingVariant, over a fixture module that declaresPointin both variants and asserts the collision itself through the realgo/typesscan (collectWhiteboxBridgeTypeNames) before exercising either split. -
The friend bridge’s box receivers survive into the pointer adapter. sync’s
export_test.godeclaresPushHead/PopTailon the production*poolDequeue/*poolChain; the converter emits them as direct-ж primaries, sopoolDequeueжPoolDequeuemust forwardm_box.PushHead(val). Two generator defects stacked: the bridge scan matched the receiver’s parameter TEXT against one composed spelling (ж<poolDequeue>) when the bridge qualifies it however its own file needs —this ж<global::go.sync_package.poolChain>once ago/*package in the closure shadows the root namespace — and the foreign-struct arm then re-derived every member’s receiver from the referenced assembly’s METADATA, where a bridge-contributed method does not exist, clobbering the binding back tom_box.Value(CS1929 ×5). The scan is now on the ж argument’s last dotted segment (well-defined because it runs only where the struct has no local declaration), and a bridge-bound member is left alone.
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:
- the emitted conversion record — hash/maphash’s
[assembly: GoImplement<Hash, hash_package.Hash64>(Pointer = true)]and crypto/hmac’sGoImplement<hmac, hash_package.Hash>, whose closures reachhashbut neverio('io_package.Writer' is defined in an assembly that is not referenced); - the go2cs-gen adapter realizing that record, whose class declaration lists the interface; and
- every converted production/test source that names it (
(fs.File, error) Open(...), hmac’sjustHash).
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:
-
Seed only from the files the test assembly COMPILES (
referencedTypeSeedswalks the retained syntax rather than iterating theTypesInfomaps, which also makes seed order deterministic). A Phase-4D compile-excluded Example/Benchmark-only file is analyzed — so its declarations still reach the manifest — but no C# is emitted for it, so it names nothing the compilation must bind. Seeding from one handedcompress/gzipfive references (context,crypto/tls,mime/multipart,net/http,net/url) reached throughhttp.Request’s fields, from anexample_test.gothat is not compiled at all, and handedgo/tokenago/astreference the same way. -
Start the interface walk from named types, never from whole packages, and follow bases transitively (
b.B : a.A : io.Writerneeds bothaandio, because a base’s own declaration must bind in turn). C# needs a base’s assembly only when the derived interface is BOUND, and walking every exported interface of every referenced package would handioto nearly the whole corpus throughfmt.State’s structural io.Writer base — a reference no project that merely callsfmt.Sprintfrequires. -
Fire the struct-field edge only where a composite literal CONSTRUCTS the struct — that, not mere value use, is what demands the field types, and one level suffices (the generated constructor’s parameters default unless supplied, and a nested literal is itself a seed). Measured against the corpus: eleven banked packages hold
sync.Once,sync.Mapandreflect.Valuevalues (strconv’s package-levelatofOnce, encoding/binary’sreflect.ValueOf) and compile clean today with no reference tosync/atomicorinternal/abi— so a “named by value” rule would add eleven references that nothing needs.os.File’s single*filefield likewise never drags internal/poll, syscall and the rest of os’s private graph in. An EMPTY literal carries the edge only for a struct declared in a ROOT package, and the boundary is ACCESSIBILITY rather than a package list:T{}rendersnew T(nil)— go2cs-gen’s dedicated nil constructor, which names no field — but the FIELDWISE overload stays a resolution candidate wherever it is visible, and binding a candidate means binding its parameter types. That constructor isinternalfor any struct with an unexported field, so outside its assembly and friends it is not a candidate at all (which is exactly why mime’sonce = sync.Once{}and testing/quick’sreturn reflect.Value{}, falseneed nothing), while a root package’s struct IS visible that way — recompiled into the test assembly, or reached through the white-boxInternalsVisibleTogrant. math/rand/v2’s*p = ChaCha8{}failedCS0012 … 'chacha8rand_package.State' … assembly that is not referencedat thenew ChaCha8(nil)expression, with internal/chacha8rand named in no import list on either side.
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 sync — handle_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 73 — bufio 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
ErrorList→error 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-deferred — discoverTestDeclarations 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
-
every top-level declaration it contributes is a Phase-4D-deferred
func Example*/func Benchmark*— imports do not count as declarations; any top-levelvar/const/type, or any other func (aTest/TestMain/Fuzzfunc, a method, or a mis-signatured Example/Benchmark), disqualifies the whole file (conservative by design;TestMain/Fuzzare deliberately out of scope). The classification is the exactisPhase4DExcludedTestFuncpredicatediscoverTestDeclarationsuses (no receiver, no results, no type params, and either a zero-parameterExample*or a single-*testing.B-parameterBenchmark*), so a file qualifies only when it truly contributes nothing to the run registry; and -
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:
-
A local named
nil(nil := 5, legal Go) was emitted as the nil-literal rendering —nint default! = 5;, a syntax error — because the ident conversion matched on the name. It now checks the resolved object (mirroring thetrue/falsehandling): only a use resolving to the universe*types.Nilrenders as the literal (default!, or golibnilin pointer contexts); a shadowing object falls through to normal rendering (nint nil = 5;—nilis not a C# keyword, and Go’s own scoping guarantees no nil-literal use while shadowed). -
User types named
builtinorsstringshadowed golib names the emitter references even when the Go source never spells them — the qualifiedbuiltin.len(…)calls (emitted when a package method shadows a built-in) bound the nested user struct (CS1501), and the string([]byte) elision’ssstringviews bound the user type (CS0030). Both names joined thereservedset: the user types decline toΔbuiltin/Δsstring. -
User types named
any,rune,nint, ornuintshadow spellings the emitter itself produces:interface{}rendersany(slice<any>bound the user struct, CS0029), an untyped rune-constant default spellsrune(c := 'x'emitsrune c = 'x';, CS0030), and Goint/uintmap to the C# native-int contextual keywords (partial struct nint { internal nint d; }is a CS0523 layout cycle, and@cannot fix a name-identity problem). These names must never enter the string-basedreservedset — legitimate emissions re-enter the same sanitizers, so corpus-wideslice<rune>(…)would corrupt toslice<Δrune>and re-fed delegate compositions corruptFunc<…, nint, nint>toΔnint. InsteadperformNameCollisionAnalysisregisters a package-level TYPE bearing one of these names (emitterSpelledTypeNames) in the package-scopednameCollisionsmap: every ident with that name in that package isΔ-renamed — exactly mirroring Go’s package-scoped shadowing — with zero effect on any other package (CNR byte-identical). -
requiredandscopedare C# 11 contextual keywords banned as type names (CS9029/CS9062, surfacing in both the converted declaration and the TypeGenerator’s output). Both joined thekeywords@-escape set likefile(partial struct @required— the@escape is valid in every position and the generator carries it through).record,partial, and the other contextual keywords compile clean as type names on C# 13/net9 (verified empirically) and stay unescaped. - The keyword set carried the typo
__argslist, which covered nothing: a Go local named__arglisthit the real (undocumented) Roslyn keyword and failed to parse (CS1002). Corrected — the local now emitsnint @__arglist = 5;.
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 (hash → hashΔ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.Second → time.ΔSecond), a Δ-shadowed import
qualifier (color.RGBA → Δcolor.RGBA), or a type alias (color.RGBA → colorꓸ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
timeꓸMonth() // 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:
- a method PROMOTED FROM AN EMBEDDED INTERFACE field (go/types gives it an interface receiver) — CS1929,
context.afterFuncCtx, whoseDeadlinepromotes fromcancelCtx’s embeddedContextinterface whileDone/Err/Valueare concretecancelCtxmethods; - a method reached through TWO OR MORE embedded structs (its receiver is neither the type nor any direct-field type; the generator hops to the first field only, one level too shallow) — CS1503, a
rig{Device{Sensor}}whoseLabellives on the twice-embeddedSensor(guarded directly by theCrossPkgUserbehavioral test).
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/bits’ TestDiv*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жReader → io_SectionReaderжReader, and (Scored)(Verdict)4 → new 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 distinction — pm{} == 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 nint — slice<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 nuint→nint 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]T → ref 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]int → new 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 package — cpuLogWrite [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 nullable — onceError? 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:
-
named struct-field declarations —
internal atomic.Int64 total;(wassync.atomic_package.Int64); -
heap-box allocations —
ref var n = ref heap(new atomic.Int32(), out var Ꮡn);; -
element-address
at<T>—…at<atomic.Int32>(0).
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 DeferTypelessReturns’
first — 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 ArrayValueCopySites’ namedAssignCopies — 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:
-
array<T>.Clone()is DEEP for nested arrays (golibarray.cs): Go’s[2][3]intcopy copies the inner arrays too, but the shallowT[]clone left every nested backing shared — so even the cloned sites under-copied at depth ≥ 2 (m.Clone()thenm[0][0] = 99wrote the source’s inner array). An element that is itself an array wrapper (anything implementingIArray) is re-cloned through itsICloneablesurface, which now returns the properly-wrapped clone so the unbox recurses through any nesting depth; thetypeofgate keeps flat element types on the single shallow copy. -
NAMED array types get a strongly-typed
Clone()(IArrayTypeTemplate/IArrayViewTypeTemplate): the wrapper’s only clone was the object-returningICloneableform, so named-array copies could not be expressed.public Row Clone() => new Row(Value.Clone());(and the view-wrapper equivalent through its underlying wrapper) lets every site above — plus the function-parameter preamble, func-literal parameters, and array-typed VALUE RECEIVERS — clone named and alias-declared arrays exactly like direct ones (typeIsArrayValuetests the underlying type, widening the old direct-*types.Arraypreamble gate). -
array<T>equality/hash is structural per-ELEMENT (golibarray.cs): Go arrays are comparable values, so amap[[2]int]Vkey must be found again by an equal array with different backing.GetHashCodehashed the backing reference (every structural-equal key missed), and theEqualsoverloads passed container-typed comparers (EqualityComparer<T[]>) whereIStructuralEquatablecalls them per boxed element — throwing on the first equal-length distinct-backing comparison. Element-typed comparers fix both and recurse through nested arrays.
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 fmt→reflect.
(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:
-
The CONVERTER decides which fields clone, not the generator. Only the converter has the Go
type information, and it must agree with itself at the copy sites;
[GoValueClone("h", "x")](golibGoValueCloneAttribute) is that single source of truth. A defined type over such a struct (type IpMaskString IpAddressString, whose underlying holds a[16]byte) is emitted as go2cs-gen’s inherited wrapper, so it is stamped[GoValueClone("Value")]and its clone forwards to that one member — without it,syscall.IpAddrString’s own clone had noΔCloneto call (CS1061). -
The stamp is written on the
package_info.csaccessibility record, not on the declaration above —[GoValueClone("h", "x")] internal partial struct digest {}— so the converted source keeps the shape of the Go struct it came from.TypeGeneratorreads it off any part of the partial type, which is also what lets a hand-owned conversion keep stamping it inline. See Extended attributes. -
The method is NOT named
Clone. A Go type may declare its ownClonemethod, which converts to an EXTENSION method on the package class — and an instance member of the same name silently SHADOWS it. Vendoredx/crypto/sha3’sfunc (d *state) Clone() ShakeHashis the real case: its recv-overload forwarder bound to the generatedstate Clone()and failed CS0029. The name isSymbols.ValueCloneMethod(ΔClone), reusing the sameΔcollision-avoidance marker the promoted-accessor rename uses. Copy SITES for plain arrays keep golib’s publicClone(); only the generated bodies use the uniform name, whicharray<T>and the named-array/array-view wrappers alias to their ownClone()so one call form covers every clone-needing field type. -
array<T>.Clone()recurses through the new marker. It already re-cloned an element that is itself an array ([2][3]int); an element that is one of these structs ([2]digest) now clones the same way, throughIGoValueClone/ICloneable. -
EMBEDDED members are never listed and never cloned. go2cs-gen holds an embed in a
ж<T>box whose member accessor writes THROUGH the box, so assigning one in a clone would corrupt the source. Embedded-struct copy aliasing is the separate, pre-existing gap (4) above; this change neither fixes nor worsens it, and a struct that needs cloning only because of an embed is not stamped.
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:
-
operator ==(slice<T>, NilType)testedLength == 0 && Capacity == 0, misclassifying every zero-length zero-capacity view ([]byte{},[]byte(""),x[len(x):]) as nil. It now testsm_array is null— representation nilness. -
operator ==(slice<T>, slice<T>)was structural content equality. Go forbids comparing two slices, so the only converted code binding this operator is the nil comparisons == nil, which renders ass == default!(the nil literal rendersdefault!in value contexts). It is now Go slice-header identity (same backing array reference, offset, length, capacity), which against the default header(null, 0, 0, 0)is exactly the nil test. Structural equality remains on theEqualsoverloads for C#-side collection use, and the generated named-type wrappers bindEquals(not this operator), so their behavior is unchanged. -
Reslice(everys[a:b]/s[a:b:c]) laundered a nil backing into a fresh empty array (m_array ?? []). Reslicing nil is legal only within zero bounds, and Go’snil[0:0]is the nil slice (the result shares the nil backing pointer) — it now returnsdefaultfor a nil source. -
Appendwith zero elements allocated a fresh empty slice for a nil source. Go’sappend(s)(andappend(s, empty...)) returnssitself — no growth is needed, so the same header comes back: nil stays nil, and bytes.Clone’sappend([]byte{}, b...)with emptybreturns the non-nil literal.Appendnow returns the source unchanged when there is nothing to add. (builtin.widen, the generic pointer-instantiation projection, likewise now projects only a nil source to nil instead of any empty one.)
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]int → internal 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 —
bitCounts’ leafCounts [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 nint — CS1660: 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:
- The backing array is private to
@string. A consumer reading it directly instead of reading the window would silently see the whole backing rather than the string, so privacy makes that a compile error rather than a wrong answer — which is how the remaining raw-array readers (sstring’s mixed comparison/concat operators,builtin.slice(@string,…),ByteSeqExtensions.ToGoString) were found and corrected. Bounds that were measured against the backing array are now measured against the window (@string.SliceBounds) — the same correctionarray<T>.slicealready carried for an alias window. -
unsafe.StringDatapins a window that does not begin at the backing array’s start by materializing its bytes first, since aGCHandlepins an object from its start. Whole-backing strings — the overwhelming majority, and every string that reached there before windows existed — pin in place unchanged.
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 @string→slice<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 → @string → byte[] 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 (0xD800–0xDFFF) 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 int → System.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 string — errorString("…") 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 []rune — htmlSig("<!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 concatenation — const 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-boxed — private 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" → 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 / dˢ 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/types’ suspendedCall)
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 (streamWriter→io.Closer in net/http/fcgi) and a foreign pointee (*ast.SelectorExpr→ast.Expr, *Basic→Type, *Func→Object 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 ipStringTestsᴛ1(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):
- a string literal (
string(buf[:4]) == "ZLIB",string(item) != "null") — the literal keeps its"…"u8span form and bindssstring’s zero-allocationReadOnlySpan<byte>comparison operators; - a pure-read plain-
stringexpression — a variable, field, or index read (string(b[:n]) != magic,string(word) != "package") — which runs no code, so it cannot write the buffer, and compares via the new mixedsstring/@stringoperators (byte-ordinal span compare, no heap copy of either side); -
another
string(bytes)conversion (string(a) == string(b)) — both become zero-copy views and comparesstring == sstring.
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 []byte→string 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 NamedMapMakeNonNil — make 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.cs — canonicalizeQualifierRename 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:
-
The POINTER kind hoists too.
type X *Twas the one forward-declaration kind still writing its[GoType("ж<…>")] partial class X;straight into the body — gob’scodec_test.gotype Rec ***RecproducedCS1525 Invalid expression term 'partial'and took the rest of the function with it. It now takesliftLocalTypeDecllike the other kinds, and the lift is taken beforeconvStarExprrenders the pointer text so a self-referential declaration resolves its own name throughliftedTypeMap. -
A SELF-REFERENTIAL local type re-resolves its element after the hoist. The array/map/channel
emitters resolved the element/key/value name before the declaration’s own hoist registered its
lifted name, so
type recursiveSlice []recursiveSlice/type recursiveMap map[string]recursiveMap(gob’sencoder_test.go) emitted[GoType("[]recursiveSlice")]on a member-levelTestRecursiveSliceType_recursiveSlice— a name that no longer exists,CS0246inside the generated slice/map partial. Each emitter now re-resolves its element throughliftedTypeMapwhen the hoist actually renamed the declaration; a package-level declaration never renames, so its emission is untouched (verified byte-identical across the whole behavioral corpus and the 302-package stdlib).
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):
-
An EMPTY clause body still needs its jump. C# requires every switch section to end in a jump statement (CS8070 on a final
default:, CS0163 otherwise); the emittedbreak;was suppressed when the previous clause ended in a terminalreturn(the was-return flag is reset per statement, and an empty body has none). The flag resets per clause now — a bare Godefault:emitsdefault: { break; }. -
A terminating blocking select gets an unreachable trailing
return default!;. Go’s spec makes a select with nodefault:whose every comm-clause body ends in a terminating statement itself terminating, so a value-returning function may end with it. The lowered form’s guardedcase N when <recv>:labels cannot prove exhaustiveness to C# (CS0161). Mirroring the switch guarded-terminal-default rule, the emission appendsreturn default!;after the closing brace — gated on: no default, every clause terminating (isTerminatingStmtList, conservative), no select-targetingbreak, a value-returning signature, and not named-return-defer mode (void wrapper).
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 selᴛ1 = fresh();
switch (select(ᐸꟷ(selᴛ1, ꓸꓸꓸ))) {
case 0 when selᴛ1.ꟷᐳ(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 selᴛ3 = ch.ᐸꟷ(8, ꓸꓸꓸ);
var selᴛ4 = ch;
switch (select(selᴛ3, ᐸꟷ(selᴛ4, ꓸꓸꓸ))) {
case 0: {
fmt.Println("send fired on full channel (wrong)");
break;
}
case 1 when selᴛ4.ꟷᐳ(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):
-
ChanCore<T>is thehchananalog: a Monitor lock, a circularT[]buffer (null whendataqsiz == 0),sendx/recvx/qcount,closed, intrusiverecvq/sendqparked-waiter queues, and a monotonicId(the total lock order for select). Thechannel<T>struct holds only a reference to its core, so the zero value is the NIL channel and struct copies share one channel.chansend/chanrecv/closechanfollow Go’s routines exactly, including the buffered-full parked-sender head-take/tail-enqueue rotation and drain-before-zero comma-ok (a closed channel yields its remaining buffered values withok == truefirst, then(zero, false)). -
make(chan T)emitsnew channel<T>(0)— capacity 0 is a real rendezvous channel;cap()isdataqsizandlen()isqcount, somake(chan T)vsmake(chan T, 1)are finally distinct (the make default inconvCallExpr.gocovers plain and named channels; the gen Channel template’s wrapper constructor no longer clampssize < 1to 1). A parked operation blocks its goroutine’s own dedicated thread (Goroutine.Start), so parking costs nobody else’s capacity and a program can park thousands of goroutines at once — the shapeGoroutineParkStormguards. golib used to queue goroutines on the shared ThreadPool and raise its min-thread floor tomax(256, 4 × processor count)to compensate; both the floor and its premise retired with the dedicated-thread executor (docs/phase4/DESIGN-cooperative-scheduler.md). -
Blocking select is a selectgo port behind the unchanged emitted text. The registration
methods (
Receiving,Sending,ᐸꟷ(ch, ꓸꓸꓸ),ch.ᐸꟷ(v, ꓸꓸꓸ)) return type-erasedSelectOpdescriptors — invisible to overload resolution at every emitted call site — andselect(params SelectOp[])partitions out nil channels (never registered), locks the distinct cores inIdorder, scans a Fisher-Yates-shuffled poll order, and commits exactly one ready op under the held locks (uniform-random single-fire, gaps 3+4); otherwise it parks oneSelectState-linked waiter per case, where a singlewinnerCAS is the single-fire authority every waker — plain send, plain receive, another select’s commit, AND close — must win before touching a waiter. Publish-before-signal ordering and park-outside-the-lock discipline throughout. -
The committed receive value crosses to the guard via a per-thread pending-frame STACK (not a
single slot):
select/trySelectpush a frame (channel core, value, ok) on a receive commit, and the winning guard (Received/ꟷᐳ) pops exactly the frame whose core matches its own channel. A stack because the guard’s out-argument TARGET expression is evaluated BEFORE the guard call, and legal Go can run another select there (case a[f()] = <-ch:wheref()selects) — the inner select pushes and pops its own frames, so the outer commit survives; a single slot was destroyed by the inner select’s entry (outer value lost, or the next buffered value stolen — found by the adversarial verification round). Only receive commits push frames; a send-case win touches nothing (a select may have send and receive cases on the SAME channel, and clearing would destroy an outer frame mid-nest). Known residual: a panic unwinding between commit and consume strands a frame — unbounded under a repeated panic-in-target/recover loop, an accepted benign memory residual. The stack must never be CAPPED: live depth is dynamic, not textual — one textual select whose out-target expression recurses holds one live frame per recursion level, so a depth cap silently drops live outer frames (theDeepSelectRecursionguard, 100 levels, falsified an attempted depth-64 cap). Frames are never MIS-consumed (every consume matches the top frame by channel core); a strand stacked above a live frame makes the outer select fire zero cases — the committed value is abandoned exactly as the panic abandoned the communication, never delivered wrongly. Debug-only depth warnings, never a process-killing assert. With no matching frame the same guards are non-blocking probes, unchanged. -
A channel may have an OWNING TIMER (Go’s
hchan.timer), the hook Go 1.23’s synchronous timer channel needs:IChannelTimeris installed bychannel<T>.AttachTimerbefore the timer is armed,Capacity/Lengthreport 0 while the owner answersHidesBuffer(Go’schanlen/chancaptimer-channel branch), andDrainBuffer()— Go’sruntime.timerchandrain— empties the buffer without servicing parked waiters, so the owner can REVOKE a value the channel already accepted. It is the only sanctioned way to un-send, and only sound for a channel whose producer owns it exclusively;IsUnbufferedkeeps reporting the physical shape. Full semantics under Realizing the runtime TIMER contract. -
Close/panic semantics are Go’s: send on closed panics (even from within a select, and even
when a
default:exists); close of closed and close of nil panic; a parked select-send woken by close panics on its own thread; parked receivers (plain and select) wake with(zero, false); range-over-channel terminates on closed-and-drained;len/capof nil are 0. A boxedIChannel’s nil comparison is representation nilness (channel is null) — the old{ Length: 0, Capacity: 0 }pattern would misclassify a live empty unbuffered channel as nil.
(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 resultᴛ1): {
ref var result = ref heap(resultᴛ1, 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 ж
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 a → return Ꮡ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 := p → var 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 == nil → ref 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 parameter — Index[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 ([]int → E = Go int → nint), 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].Type — int for the []int caller, byte for a []byte caller, int64→long, uint→nuint, …), 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:
- a named type over signed
int64—type ProcID int64indexingspans[procID](CS1503). There is nothis[long]overload,int64→nintdoes not narrow implicitly, andint64→ulong(which would bindthis[ulong]) is a signed→unsigned conversion — so it has no bare path.(nint)(procID)composes as one user conversion (named→long) plus one built-in (long→nint). Every other kind is deliberately excluded — no churn, and casting some would even break: an unsigned named type (type kindT uint/uint32/uint64) binds the golibthis[ulong]overload bare, and a nuint-backed wrapper (uint/uintptr) is CS0030 under a(nint)cast; anint/int32/nintunderlying narrows implicitly (type rank intstays bare). So only signedint64both needs and accepts the cast; - a numeric type parameter —
dataTable[EI ~uint64]doingd.dense[id]. A constrained type parameter has no C# cast at all, so it routes through golib’sConvertToUInt64<K>bridge (theE(100)integer-type-param family above) and then narrows:d.dense[(nint)(ConvertToUInt64<EI>(id))](an arithmetic indexid/8is stillEI, so it wraps the same way).
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, elemᴛ1 => new IdentжNode(elemᴛ1)));
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:
-
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-1where Point : nistPoint<Point>(CS0308) and every bareTis undefined (CS0246). (Go’s operator-only constraint interfaces are arity-0 in Go, so this is disjoint from the<ΔT>operator machinery.) -
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’sGoImplementrecords are per-instantiation but all resolve to the open form here, soImplementGeneratorde-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 oldnistCurve<…>жCurvewas CS1526). -
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 proxyP224Pointж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.EmitConstraintProxyemits: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жnistPointsatisfieswhere Point : nistPoint<Point>(CS0311 otherwise) and resolves everyp.Add(…)/newPoint().SetBytes(…)call insidenistCurve’s body. The implicitж<P224Point>↔proxy conversions do all the T-boundary marshalling automatically: each forwarder is a barem_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 aPoint-typed position (base: Ꮡ(new P224Point(…))) converts implicitly at the site. The proxy forwards to the element’s exportedж-extensions even cross-assembly (m_box.SetBytesbinds nistec’s extension from crypto/elliptic). -
A
func()-typed field’s method-group initializer is re-wrapped as a lambda.nistCurve’snewPoint func() PointbecomesFunc<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):
-
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 typednistCurve<P256PointжnistPoint>was mis-sliced into garbage (its simple name becameoint.Value, its underlying name an unresolvable string) and the embed promoted nothing (CS1061 onparams, 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. -
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 barenistCurve). The promoted field and method signatures are harvested from the declaration, so they carry its type PARAMETER (Func<Point>,pointFromAffinereturning(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, ж<bigꓸInt> Ꮡx, ж<bigꓸInt> Ꮡy) => target.nistCurve.pointFromAffine(Ꮡx, Ꮡy);— so no promoted member references the out-of-scope
Point. (The member ACCESS hop keeps the bare property namenistCurve; only the emitted TYPE is substituted.) When the ENCLOSING struct is itself GENERIC —wrapped<T>embeddingtag<T>(theGenericStructFieldsguard) — 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 identityT→T), else theTin the receiver and return is an undefined type name (CS0246). -
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, namespacego.crypto.@internal). The[assembly: GoImplement<…>(ConstraintProxy = true)]attribute driving the proxy sits inpackage_info.cs, whose usings never cover a FOREIGN element, so the forwarders bound nothing (ж<P224Point>“has noBytes”, CS1929/CS1501).EmitConstraintProxynow emitsusing <element-namespace>;for the box element’s namespace. -
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, …)withc *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 argumentPointis out of scope in an assembly attribute (CS0246).convertToInterfaceTypenow 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 TestFloat64s — Float64s → slices.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.
-
Sub-slicing moves to the self-referential
TSelf this[Range]. Both implementers’ public range indexers already return their own type, so this is satisfied implicitly, and the call becomes aconstrained.direct dispatch on the value type. The converter’s((bytes)(…))cast survives as a no-op identity conversion. -
lentakes the constrained type parameter (len<TSeq>(TSeq) where TSeq : IByteSeq) instead of the interface. -
[]byte(s)/string(s)become theToSlice/ToGoStringextension methods rather than constructors. C# has no generic constructor, so a constructor can only accept the interface; and a static factory cannot even be named here, because converted code carriesusing static go.builtin, which shadows thesliceand@stringtype names with the builtin conversion methods (slice<byte>.From(s)is CS0119 — “is a method, which is not valid in the given context”). An extension call is member access on the receiver, so it sidesteps both problems. Each foldstypeof(TSeq) == typeof(…)to a per-instantiation constant, so the sharing case reduces to a field copy.
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 dispatch — for _, 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 dispatch — words{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/http → go.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 value — d.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 time — go 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 GoStmtReceiverLambda — go 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 type — metricReader(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:
- The lambda is unnecessary here. A pointer-receiver method value binds once and aliases, so the group is both simpler and strictly more faithful than the lambda’s documented per-call receiver re-evaluation. (The lambda remains right for a value receiver, where C# cannot build a delegate over a value-type extension at all.)
- The receiver snapshot was the deeper error, and only a local receiver exposes it. The snapshot
exists to preserve a value receiver’s bind-a-COPY semantics, which a pointer receiver does not
have — the escape analysis has already ruled the other way by heap-boxing the local (the rule
A pointer-receiver METHOD VALUE heap-boxes its receiver above) — and it renames the receiver, so
the synthesized
&producedᏑcʗ1, a box nothing declares (CS0103).visitAssignStmtnow skips the snapshot for this shape at both of its method-value sites (methodValueBindsReceiverAddress).
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:
- the
catchparks a panic — an explicitpanic()or a .NET exception that maps to a Go runtime panic — whererecover()can read it. The filter is the single adoption point that also snapshots the panic’s origin, so a non-panic exception (andGoexitException, deliberately) fails it and propagates unchanged; - the
finallydrains the deferred calls, which is Go’s guarantee that they run on every exit path — normal return, panic,runtime.Goexit, or a mapped runtime fault. They run after the panic has been parked, which is exactly what lets a deferred call recover the panic raised by the body it was registered in; - the frame is the defer list. It is a
ref struct, so it lives in the method’s own stack frame, the JIT can enregister its four inline slots, and the machinery allocates nothing.
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:
- a capture-semantics divergence class, by construction. A body-owning lambda closes over variables the Go original never closed over; an inline body closes over nothing at all, and only the deferred closures capture — exactly as Go’s deferred closures do.
-
the ref-parameter ladder. A variadic deferring function would have to thread its
params Spanthrough the wrapper, because a lambda cannot capture one. An inline body has no parameters to thread, so it simply uses the one it has (GenericVariadicFunc,VariadicPointerParam).
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.
-
Named results + defer. See The named-result form above: the results are declared before the
tryand read back after thefinally, and every exit inside leaves through agoto. -
IIFEs. An immediately-invoked function literal that itself uses defer/recover carries its own frame inside its own delegate-cast invocation (
((Action)(() => { GoFrame ... }))()), so its defers are its own – while itsrecover(), being a static call, still reads the one panic slot. -
A
returnemits against ITS OWN function’s results, not the enclosing function’s. A barereturnin a function with named results emitsreturn (n, ok);(the named results). A nested function literal must be converted against its own signature – otherwise a barereturninside a void closure would inherit the enclosing function’s named results and emitreturn (n, ok);into avoidlambda (CS8030, “anonymous function converted to a void-returning delegate cannot return a value”). Runtimemprof.goroutineProfileWithLabelsSync(named(n, ok)) passesforEachGRace(func(gp1 *g) { ...; return; ... })– the void closure’s bare returns must stayreturn;. The return signature is tracked separately fromcurrentFuncSignature(which stays the enclosing function’s, so the receiver/parameter detection still resolves a captured pointer parameter – an outer parameter – correctly):convFuncLitsets a dedicated return-signature to the literal’s own signature with save/restore, andvisitReturnStmtemits results against it. (Guarded by theClosureBareReturnNamedResultsbehavioral test – a void closure with bare returns nested in a named-results function, output verified vs Go; cleared runtime’s 4 CS8030.) -
Return-type INFERENCE is not a concern. An inline body returns against the METHOD’s own declared result type, so no inference runs over the return statements and the two shapes that would defeat one cannot arise: every return carrying an untyped
default!(Gonil– syscall’sgetProcessEntry), and returns of two unrelated concrete types sharing only the declared interface (go/parser’sparseTypeNamereturning&ast.SelectorExpr{...}beside a plain*ast.Ident). Both are pinned as guards:DeferTypelessReturns(unnamed results, a defer, and every return carrying nil) andDeferInterfaceReturn(a defer/recover func returningShapeviaCirclevsSquare, plus a heterogeneous(Shape, bool)tuple return).
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 ж
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 embeds — lazyCert.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 exprᴛ1 = CrossPkgLib.Precision;
if (exprᴛ1 == 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(exprᴛ1, 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:
- the pattern-match decision excludes any named-wrapper tag (
tagIsNamedWrapper, beside the existingnamedTypes/tagIsStaticReadonlyConstgates — those could not catch the mixed const-ident + literal switch, because the per-label screening short-circuits onceallConstgoes false and never reaches its named-type check); - a CONSTANT label that is not an ident/selector/conversion-call (those already render AT the wrapper type) casts to the tag type:
var exprᴛ1 = code;
if (exprᴛ1 == socksStatusSucceeded) { // named-const label — no cast
return "succeeded"u8;
}
if (exprᴛ1 == (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 exprᴛ1 = v.Kind();
var matchᴛ1 = false;
var matchᴛ2 = exprᴛ1 == reflect.ΔInterface || (exprᴛ1 == reflect.Array || exprᴛ1 == reflect.ΔSlice);
if (exprᴛ1 == reflect.ΔInterface) { matchᴛ1 = true; … fallthrough = true; }
if (fallthrough || !matchᴛ2) { /* default: */ … }
if (exprᴛ1 == reflect.Array || exprᴛ1 == reflect.ΔSlice) { matchᴛ1 = 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 place — if (!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 (exprᴛ2 == syscall.WAIT_OBJECT_0) { do { break; } while (false); }
else if (exprᴛ2 == 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 (exprᴛ1 == stdISO8601ColonTZ || …) { matchᴛ1 = 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 (uintptr→nuint, rune→int32, byte→uint8) 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 int → public 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 (convMapType → getExpressionTypeName) 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 reservedᴛ1 {
public global::go.go.types_package.ΔType Type;
}
internal static ж<reservedᴛ1> reserved = @new<reservedᴛ1>();
p.typeList[n] = new reservedᴛ1жΔ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 ptrElemsᴛ1 {
internal nint @in;
internal @string str;
internal error error;
}
internal static slice<ж<ptrElemsᴛ1>> ptrElems = new ж<ptrElemsᴛ1>[]{
Ꮡ(new ptrElemsᴛ1(1, "one"u8, default!)),
Ꮡ(new ptrElemsᴛ1(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ᴛ1…eᴛ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:
-
It runs in ordinary conversion too, exactly as
siblingTestFuncMethodNamesdoes for reference spelling, so a package’s production storage shape is mode-stable — an-stdlibreconvert and a-testsrun emit the same bytes. Conditioning it on-testswould make the banked corpus flip between the two. - The scan is a cheap direct directory read, not a second type-check — no test dependency graph is loaded. It is therefore name-based, and the production pass resolves each candidate against the real package scope, dropping anything that is not a package-level var (a type, a func, an import qualifier, a name that exists only in the test file).
-
It errs toward recording nothing. Names bound anywhere inside the enclosing top-level
declaration — receiver, parameters, results,
:=,var/const/type, range and type-switch bindings — are excluded, so&counteron a local that shadows a global does not box the global. Under-recording restores today’s loud CS0103; over-recording would silently box a global no pointer aliases.
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 switchᴛ1 = next(x);
switch (switchᴛ1.type()) {
case @string _:
case bool _: {
var v = switchᴛ1; // re-bind reads the temp — next() ran exactly once
…
default: {
var v = switchᴛ1;
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:
-
The closure was computed per PACKAGE, not per ASSEMBLY. A
-testsrun recompiles the package’s PRODUCTION sources into the test assembly, so that assembly’s reference closure is the UNION of the production and_test.goclosures. The production conversion pass saw only its own half, never learnedgo.gowas in scope, and emitted bareusing bits = go.math.bits_package;into a compilation that did containgo.go.collectSiblingTestClosurenow runs a metadata-only (NeedName|NeedImports|NeedDeps) load of the test variants before the production conversion and records their transitive import paths insiblingClosureImportPaths, whichcomputeImportAliasRenamesfolds 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--testsconversion, so no other output moves. -
Targets composed straight from
packageNamespacebypassedrootQualifiedentirely. Both the package-under-test anchor (visitImportSpec’sisPackageUnderTestbranch, which REPLACES therootQualifyIfAmbiguous-derived target with<packageNamespace>.<pkg>_package) and the test host’susing go.testing_runtime;were bare, which is why one emitted file could show a correctly-qualifiedusing iotest = global::go.testing.iotest_package;beside a brokenusing static go.math.rand.rand_package;.globalQualifyRootedapplies 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 isStrippedGoPathPackageRef → using 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.emptyInterfaceArgs →
LambdaContext.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:
-
Structurally identical anonymous struct types are ONE Go type. Repeated textual
occurrences (
new(struct{ A Struct })four times in encoding/binary’s TestSizeStructCache) must lift to a SINGLE C# type — per-occurrence lifts splitreflect.Typeidentity per occurrence, so binary’sstructSizecache gained four entries where Go adds one. Lifted anonymous structs dedupe by structural signature within a function; NAMED local declarations keep per-declaration identity and never dedupe. (The cross-function/package-level anonymous split is a recorded residual.) -
A lifted local NAMED type carries its original Go name via the golib
[GoLocalName]attribute — a SEPARATE attribute, never a[GoType]definition token (the TypeGenerator matches that slot by exact string and throws on unknown forms). The reflection bridge’s naming (GoReflect.GoQualifiedName→Type.String(),%T) prefers it, so a local type prints Go’s*binary.Person, never the lifted*binary.TestNoFixedSize_Person(TestNoFixedSize asserts the exact error text). Being read off the runtimeTypeis also what makes the stamp movable, so it is written on thepackage_info.csaccessibility record and the lifted declaration reads as the plain lift it is (Extended attributes):func TestNoFixedSize(t *testing.T) { type Person struct { … }```csharp [GoType(“dyn”)] partial struct TestNoFixedSize_Person {
// 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:
- it has methods (its method set is meaningful — the
FirstClassFunctions/hashFuncwrap case below still applies); - it is generic (it is referenced as
Seq<V>, and the type parameter must stay in scope — see the generic-Seqrange-over-func case); - its signature references another named func type, including itself. A self-referential func
type —
type stateFn func(*machine) stateFn(a Go state machine,NamedFuncTypeStateMachine) — has no finite base-delegate form (Func<M, Func<M, …>>is infinite); and a reference to another named func type (strategy func(score) action) would leave that name undefined after collapse. Only the leaves of the func-type reference graph collapse; a referencing type stays named and renders the collapsed leaf inside its own signature.
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.ΔPosition →
tokenꓸPosition), go/internal/gccgoimporter (a malformed (io.ReadCloser>, error) → valid),
internal/trace/traceviewer (net.http_package.Request → http.Request), and path/filepath
(io.fs_package.DirEntry → fs.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 package — os.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
target — os.FileInfo → fs.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ꓸFileInfo → fs.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.FileInfo →
io/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 getCSharpTypeName →
iifeDelegateType, 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<reflectꓸValue>, ж<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>):
- the named-function convention (
internal static @string gather(@string prefix, params ꓸꓸꓸnint valsʗp)) converts as a method group —apply(gather)stays bare; - a variadic func literal (
(@string prefix, params ꓸꓸꓸnint valsʗp) => …, C# 13 params lambda) converts natively — go/types’comparable(typ, true, default!, (@string format, params ꓸꓸꓸany argsʗp) => {…})now binds itsActionꓸꓸꓸ<@string, any>parameter; - calls through the value pass loose args or an empty tail via C#
paramsexpansion, and a Go spread (f(nums...)) binds the slice’s.ꓸꓸꓸSpan in normal form; - a C# consumer calls a transpiled printf-style callback naturally (
ctx.Logf("…", a, b)) — the library use case that ruled out the pack-into-a-slice<T>alternative.
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
getCSharpTypeName → iifeDelegateType, 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:
-
using-alias + namespace emission —convertImportPathToNamespace(visitImportSpec.go) rewrites the last path part to the parent segment viamajorVersionSegmentRegex, so the file’susing rand = go.math.rand.rand_package;and the package’s ownnamespace go.math.randagree. -
t.String()-based FQ type rendering —getAliasQualifiedTypeName/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/vNtail it left the version behind (math/rand/v2.Rand→v2.Rand), which the alias-prepend then doubled intorand.v2.Rand(v2read as a member of classrand_package— CS0426). Both renderers now reduce the foreign import-PATH qualifier to the package NAME before the slash-strip.getFullyQualifiedTypeNamealso composespkg.Path()+"_package"directly for the qualified base name — routed throughpackageClassPath, which swaps a/vNtail for the Go package name. -
Cross-package reference metadata —
PackageInfo.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);rootPackageNameFromPathPartsnow returns the parent segment for a/vNtail.PackageNamestays path-formed — it also names the referenced.csproj, which ISmath.rand.v2.csproj. -
Imported type-alias TARGET class —
loadImportedTypeAliases(importOperations.go) qualifies an imported alias’s target asgo.<PackageName>_package.<Type>; the class path isPackageNamewith its final segment replaced byRootPackageName, so a/vNproducer’s exported aliases resolve torand_package, notv2_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 (getProjectName → getCoreSanitizedIdentifier) 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:
-
A non-ASCII byte is a path byte. Every delimiter this scan looks for is a constructor
character and all of them are ASCII, but the converter’s own synthetic markers are not (
ᴛ,ж,Ꮡ,ꓸ,Δ). Treating a multi-byte rune as a delimiter stranded the scan inside the type name, freezing the path in front of it:go.main_package/entryᴛ1forgo.main_package.entryᴛ1. ThePublicizedInterfaceAnonAliasandNestedAliasUserlifted-alias goldens are what surfaced it. -
The scan stops at the generic-argument bracket. Past it lie type ARGUMENTS whose
,, space and]are not constructor text and would strand the scan at the tail of the string.
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 stackWorkBuf → stackWorkBufHdr → workbufhdr.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 call — sc.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 (GetExtensionMethods → IsExtensionMethodForStruct) 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).
Promoted pointer methods descend multi-hop value-embed chains
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 New → once.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):
-
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. -
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’sruntime error: invalid memory address or nil pointer dereference(recoverable) instead of surfacing an unrecoverableNullReferenceException, 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 readUnlock →
destroy → runtime_Semrelease(&fd.csema) wakes it, and those semaphores are keyed by pointer
identity. The two spellings of &fd.csema — os.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.Section → io.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_LabeledᴠlocalLabel(foreign);
fmt.Println(labelOf(new CrossPkgLib_LabeledᴠlocalLabel(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:
-
Only impl types declared in the current package are recorded.
ImplementGeneratorrealizes the attribute by emitting apartial struct <Impl> : <Interface>into the current package’s namespace and class — so it can only add an interface to a type defined in the same assembly. A pairing whose impl type is imported from another package (e.g.image/color/palettebuilding[]color.Color{ color.RGBA{…} }) is therefore not re-emitted in the consumer: that relationship is already established in the impl type’s own package (image/colorrecords[assembly: GoImplement<ΔRGBA, Color>]). Re-emitting it in a consumer would generate a broken cross-assembly partial (a fresh emptypalette_package.ΔRGBArather than the realcolor_package.ΔRGBA), so the converter skips any pairing whose impl type is not local. -
Multi-segment interface references are root-qualified. The
GoImplementattributes are emitted before the file’snamespacewith onlyusing go;in scope; that directive imports the types of namespacego(so a top-levelio_package.Writerresolves unqualified) but not its nested namespaces. A multi-segment package class such ascontainer.heap_package.Interfaceis therefore root-qualified togo.container.heap_package.Interfaceso it resolves; single-segment refs (io_package,sort_package) are left unchanged.
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→ReadDirFS — os.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(ΔSpeakerᴛObj), "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 ΔSpeakerᴛObj : 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 (ΔHandle → Handle) 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 T → IжAdapter unwrap → AdapterRegistry (compile-time adapters) → shell memo → Implements<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:
-
It was the last unbelted
MakeGenericMethodin the assert path.MakeGenericMethodover a run-time type is dynamic code: under Native AOT it succeeds only for an instantiationilcalready rooted, and there was no fallback tier — an unavailable instantiation was an unrecoverable MISS, silently wrong rather than degraded. The shells’IsValueTypebranch answers the same case with a shell that needs no instantiation, and belts the other way when it does. -
Its static members were on the interface. A converted interface’s statics are inherited by every interface that EMBEDS it, so
interface{ error; Temporary() bool }forwardedᴛAsoverloads into its own wrapper (CS0102 ×6) and demanded a static helper from the dynamic value’s Go method set — both had to be filtered out downstream. Attribute discovery removes the shape rather than the symptom. -
Its binding used the by-name extension lookup.
GetExtensionMethodcollapses a closedж<X>to the openж<>definition — right for single-dispatch precedence, wrong for a method-set query — so a method name shared across types could bind another type’s receiver.AdapterBindermatches on element identity.
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 Labeled — var 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):
- a pointer-sourced ж adapter (
byteReplacerжreplacer : IжAdapter) stands in for the*Tit wraps → renders*strings.byteReplacer; - a value-sourced foreign ᴠ adapter (
typelib_Markᴠstamper) wraps a struct copy → renders the struct type,typelib.Mark; - an interface-to-interface adapter (
IInterfaceAdapter) forwards to its wrapped value’s dynamic type; - a raw receiver box
ж<T>(a pointer held in ananywith no adapter in its history) renders*main.loud; - a converted named type package-qualifies from its
<pkg>_packagedeclaring class:go.main_package+soft→main.soft.
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 conversion — crypto.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.Signal→os.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 method — ast.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:
-
ImplementGenerator’s emitted type positions. A LOCAL struct’s name reaches the generator as a bare Roslyn SYMBOL name — UNescaped, unlike display strings (ToDisplayString()usesCSharpErrorMessageFormat, which escapes, sogo.main_package.@lockarrives correct). Emitting the raw name producedpartial struct fixed : sizer— which the C# parser reads as a fixed-size-buffer declaration, ejecting mangled members into the static…_packagecontainer (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 appliesEscapeCsKeywordat those emission sites (InterfaceImplTemplate.StructName, the pointer adapter’s wrappedStructName, and the value-embed hop’s class qualifier); it is a no-op for every non-keyword name. -
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 emittednew @fixedж@lock(Ꮡf), which lexes as TWO tokens (@fixedж+@lock— CS1526). Both composers now build from UNESCAPED simple names — the converter’sadapterTypeRef/valueAdapterTypeRefviastripSanitizationMarkers(which also clears a pre-qualifiedos_@fixed-style interior marker), and the generator’sAdapterNamecompositions viaGetUnsanitizedIdentifier— producingfixedж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:
-
go2cs-gen(StructTypeTemplate) — for a direct, non-generic VALUE embed, harvest the embed’s box-receiver primaries (GetBoxReceiverExtensionMethods, previously collected only for POINTER embeds) and, for each exported one, emit a single box-only shim (IsValueEmbedBoxRecv) that performs the descent internally, where theinternalaccessor is reachable:public static void Errorf(this ж<T> Ꮡtarget, @string format, params Span<object> argsʗp) => Ꮡtarget.of(T.Ꮡcommon).Errorf(format, argsʗp);No
this ref Toverload (a box receiver cannot bind on a value). The shim scope is the sharedmethodScope— the STRUCT’s exportedness, downgraded for a non-public return type — so it ispubliconly for an exported method on an EXPORTED struct returning void/public (the genuinely reachable case), andinternalon an UNEXPORTED enclosing struct (context’safterFuncCtx, reflect’sstructTypeUncommon), whoseж<T>receiver is itself internal — apublicshim there is CS0051. It is gated to an exported promoted method (an unexported one is never reachable across packages, so it needs no shim; its in-package callers keep the inline descent). The value embed is discriminated by!promotedStructType.Contains("<")(a plain value embed’s type name never carries<, whereas the pointer-box formж<…>and generic embeds do) — a more robust test than the@-keyword-escapedpointerEmbedTypeNamesmembership, whoseж<@file>-shaped names mismatch and mis-fired the shim onto os.File’s*filePOINTER embed (a strayFile.Ꮡfile.Value, CS0119). The embed’s own exportedness is NOT part of that discrimination — it was, until r56g, and the restriction was never a Go rule; see A value embed promotes its pointer-receiver methods into the outer POINTER method set below for why the narrower gate silently truncated a Go method set rather than merely skipping an unreachable shim. -
the converter (
convSelectorExpr) — when the promoted-method descent is reached through an unexported embed of a FOREIGN package (single hop), it drops the inaccessible.of(…)view and calls the promoted method DIRECTLY on the receiver box, binding the public shim:Ꮡt.Errorf("…"u8, …); // cross-package (Ꮡt for a deref'd param, tΔ1 for a lambda box param)The box is recovered from the first-hop
&embed-address the&-machinery already computes (the text before its last.of(), so it is correct for every receiver kind without re-deriving it.
(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 ReadCloser→fs.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 form —
global::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.ConnᴠReader(c) // referenced (arm composed the name unprefixed)
public sealed class net_ConnᴠReader : … // 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) tupleᴛ1ʗ = parts();
internal static nint g = combine(tupleᴛ1ʗ.Item1, tupleᴛ1ʗ.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
unixDirent→fs.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 ж
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 ecdhꓸPublicKeyжinterface{Equal(x crypto.PublicKey) bool}(Ꮡ(new ecdhꓸPublicKey(nil)));
// after:
[GoType("dyn")] partial interface _ᴛ1 { bool Equal(cryptoꓸPublicKey x); }
internal static _ᴛ1 _ᴛ1ʗ = new ecdh.ΔPublicKeyж_ᴛ1(Ꮡ(new ecdhꓸPublicKey(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 compareTestsᴛ1 { … }
internal static slice<compareTestsᴛ1> compareTests = …;
// main.cs (cross-file range + heap box):
foreach (var (_, vᴛ1) in compareTests) {
ref var tt = ref heap(new compareTestsᴛ1(), 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:
- A
[module: GoManualConversion]file does not claim. Its emission is redirected to a non-compiled.cs.autoreview sibling, so a claim there would push a real file’s type name to a higher ordinal for a declaration that never compiles. Those visitors keep the per-file set alone. - The
-testsINTERNAL variant is pre-seeded with the names the production conversion claimed (productionLiftedTypeNames). That variant emits its_test.gofiles into the production package class while the production.cson disk are not regenerated, so those names are immutable and the test-side lift is the side that moves — the same production-pinned ruletestMethodRenamesapplies to declarators and the Tier-C hoist seed applies to literal fields. The seed is the production run’s live claim set, captured inconvertTestVariantsbefore the first variant’sresetPackageState(production conversion runs moments earlier in the same process). The EXTERNAL variant is not seeded: its<pkg>_test_packageis a separate class and may reuse every production name freely.
// type.cs (production, pinned): encoder_test.cs (internal variant, steps around):
[GoType("dyn")] partial struct Δtype { [GoType("dyn")] partial struct Δtypeᴛ7 {
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(settingsᴛ1.Ꮡ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 (iᴛ1, _) in s) {
ref var i = ref heap(new nint(), out var Ꮡi); // a FRESH box each iteration
i = iᴛ1;
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/ulong→nint 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).a → 0) — 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-qualified — at<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 shares — boxes 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 —
-
THE nil pointer (
m_isNull: nil-constructed, or the canonicalNilBox) — the only one that is actually nil; -
a real address whose reference-typed pointee is legitimately nil —
&iwithi == nil,new(any), a closure-capturedp *Tlocal (ж<ж<T>>). In Go these are ordinary non-nil addresses, and*pyields the nil value rather than panicking; -
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, leavingm_valan unuseddefaultthat 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.f — nonnil 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:
-
One star is ONE deref.
*ppon a**Tyields a*T— a single.Value/.ValueSlothop, never two. (An older arm added an extra.Valueper pointer depth, double-dereferencing every single-star of a double-pointer field — runtimemheap.go’sspecialsIterwalk failed CS0029 in both assignment directions.) A genuine**ppis two nestedStarExprs, 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. -
A deref whose result is still reference-like reads
ValueSlot, notValue. Go’s*ppmay legally yield nil (*pp != nilis the loop condition); only dereferencing that nil panics. golib’s strictValueaccessor 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 = nlands in the original global storage. A deref producing a plain value keeps the strictValue(a nil*nodederef 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. -
&globalon an addressed global is the identity box, never a copy.&allm(wherevar allm *mis 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:
- a closure’s writes land in a divorced copy:
bump := func() { t.total += 100 }never affectst(probe-proven: Go 106, snapshot 6); - body writes after the lambda’s creation are invisible to a read-only closure:
get := func() int { return t.total }; t.total += 10reads the stale copy (Go 15, snapshot 5); - a deferred literal observes the variable’s registration-time value instead of Go’s final value (
defer func(){ fmt.Println(t.total) }(); t.total = 42— Go 42, snapshot 5); - two closures over one variable each get their own copy, so a writer and a reader stop communicating entirely.
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:
-
No defer frame (plain function, or a closure writing the result): the full escaping-local form at the declaration site —
ref var err = ref heap<error>(out var Ꮡerr);— body and bare returns keep reading the plain alias, nested closures read/writeᏑerr.ValueSlot. A value-type result with&xgetsref var x = ref heap(new nint(), out var Ꮡx);, making the write through&xvisible to the bare return (previously that shape was also CS0103). -
namedReturnDeferMode (a function whose results are declared before the frame’s
try): the decls sit OUTSIDE thetry, and the deferred closures that read them are lambdas, which cannot capture areflocal (CS8175) — so the outside line creates only the box (heap<error>(out var Ꮡerr);), thetryre-derives the value alias inside (ref var err = ref Ꮡerr.ValueSlot;, exactly like a deref’d pointer parameter’sref var fd = ref Ꮡfd.Value;), and the post-finallyreturn reads through the box:ᒐdone: return (written, Ꮡerr.ValueSlot);(internal/pollsendfile_windows.cs). -
Func-literal sibling (a literal with named results + defer + post-capture writes): same split, except the literal’s body is itself a lambda conversion, so every in-
tryuse already renders through the box — including the explicit-return rewrite’s assignment targets ((var v, Ꮡe.ValueSlot) = pair(n);) — and the literal’s trailingreturn (w, Ꮡe.ValueSlot);reads the box.
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 pointer — func (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 Ꮡc — new 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 ж
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:
-
Compare the box, not the value alias. The loop guard
p != nilmust emitᏑp != nil(the box). Each binary operand’s pointer context is otherwise taken from the other operand’s pointer-ness, andnilis not a pointer type — so the param would convert in value form (p != nil, comparing anodestruct 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. -
Nil-deferring re-alias. On the final step
p.nextis nil, soᏑp = p.nextrepoints the box to nil; re-aliasing through the plainᏑp.Valuegetter 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 bindsUnsafe.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 body — UnmarshalText 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 pointer — bh.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 ¶m 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 local — h.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 field — o.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 (scav→index). 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 latent throw. golib’s
SizeofridesMarshal.SizeOf<T>, which throws for a non-blittableT— and a converted Go struct is non-blittable as soon as it holds aslice<T>,@string, interface, orж<T>field.debug/elf’sHeader32holds anarray<byte>(abyte[]inside), so itsunsafe.Sizeof(hdr)answered 56 where Go says 52 (a 4-byte-too-long header read), and the reader’s ownSection/Prog— embedded struct +io.ReaderAt+ж<SectionReader>— throwArgumentException: … cannot be marshaled as an unmanaged structure.runtime.mapiterinit’sunsafe.Sizeof(hiter{})/goarch.PtrSize != 12guard sits in front of every map range, andhiterholds fourж<…>fields. -
Semantic fidelity. A reflection answer measures the CLR-marshalled layout, which differs from
Go’s whenever golib’s field representation differs —
stringis 16 bytes in Go/amd64,@stringis a managed struct.debug/elfuses these values as on-disk format offsets; they must be Go’s. -
Promoted fields. Go measures
unsafe.Offsetof(e.count)against the operand struct: withcountat 8 inside an embeddedPaddedthat itself sits at 8, the answer is 16. The reflection form could see only one hop of the embedding chain;go/typesfolds the whole path.
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 packages — runtime 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-uintptruse; 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.EvalSymlinks → toNorm → normBase 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 netpollinit → stdcall2(_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 p → return Ꮡ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 file — time.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.ObjectIdentifier — getAliasQualifiedTypeName 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:
-
Named func types implementing interfaces (flag’s
funcValue): a delegate cannot be a partial struct — the generator routes Delegate records to the VALUE adapter (new funcValueᴠValue(v)), whose Go methods are package extensions binding on the wrapped copy; non-struct record kinds SKIP rather than throw (a throw kills the package’s entire generator run). Guarded byFirstClassFunctions(handler.tag). -
Ref receivers never take box renders: a
[GoRecv] refreceiver has NO box — the escape-heap and lambda-capture convIdent arms fall through to the value alias (flate init’sd.fill = (*compressor).fillStoreemitted a nonexistentᏑd, CS0103). Guarded byFirstClassFunctions(worker). -
Func-field callees drive argument treatment:
getFunctionSignatureresolves a FUNC-typed field’s signature (d.fill(d, b)— the receiver arg renders as the box for aж<T>slot, CS1503). -
Integer wrappers carry the UntypedInt bridge (
(token)(endBlockMarker)— C# never chains two user conversions, CS0030). Guarded bySortArrayType(levelToken). -
Package aliases shadowed by method names qualify through the
_packageclass (sort_package.Sort(…)— flate’sbyLiteral.sortbound the method group, CS0119). Guarded bySortArrayType(PeopleByAge.sort). Same-package tests also participate in that shadow set even during an ordinary production conversion. Under recompile fallback an in-package_test.godeclaration lands in the SAME C# package class and can shadow a production alias; under white-box reference it lives in a bridge, but external-test references still need one stable production spelling — yet go/packages’ production package omits test files. hash/maphash’ssmhasher_test.godeclaresfunc (k *bytesKey) bits() intwhilemaphash_purego.goimportsmath/bits, sobits.Mul64(a, b)binds the method group in the test assembly (CS0119, plus CS8130 on both deconstructed results). Every production package conversion therefore performs a cheap, build-constraint-aware directory scan of its in-package_test.gofunction/method names and folds them intopackageFuncMethodNames; it loads and type-checks no test dependency graph. External-package tests are excluded because they emit into another C# package class. This makes production output mode-stable: ordinary and-testsconversion both emitmath.bits_package.Mul64(a, b). When a test-only declaration is specifically responsible, the statement also explains the otherwise surprising spelling:// Fully qualified to avoid alias shadowing by the same-package test declaration "bits". var (hi, lo) = math.bits_package.Mul64(a, b);This remains a REFERENCE-spelling change only — never a symbol rename, so production names stay pinned (see
testMethodRenames). On entry toprocessTestConversionthe sibling list is cleared: the in-package variant sees the declaration in its own typed universe, while the external variant’s declarations live in a different C# class. Guarded byTestSiblingTestDeclaratorsContributeAliasShadow, including build-excluded and external-test negatives plus the exact explanatory comment; hash/maphash’s normal-vs--testsproduction file is also byte-identical. A Δ-renamed foreign CONST reached through that fallback must still substitute the renamed member: the composed lookup key (time_package.Second) misses the alias map (keyed on the plain package name,time.Second), sogetAliasedTypeNameretries with thePackageSuffixstripped and, on a CONST hit, keeps the_packagequalifier while substituting the alias —time_package.ΔSecond(crypto/tls’sConfig.timemethod × time’sSecondconst-vs-Time.Second()collision; the raw name bound theSecond(this Time)extension method group, CS0019 ×2). Gated to consts: const entries exist only for collision-renamed members, while type entries cover every exported type, whose raw_package-qualified renders already bind. Guarded byShadowedImportConstLib/ShadowedImportConstUser(the lib Δ-renamesPeakfor its ownMeter.Peakcollision; the user’sgauge.ShadowedImportConstLibmethod shadows the import andSpan(2) * ShadowedImportConstLib.Peakreaches the renamed const through the fallback, output-compared vs Go). -
Blank params synthesize names when the body discards (
_ = b[7]bound the blanklittleEndianreceiver, CS0029) — encoding/binary’s bounds-check hints. Guarded byTypeSwitch(marker.tag). -
Defined-over-named-struct composites wrap the underlying (
decoder{order: o}→new decoder(new coder(order: o)), CS1739). Guarded byNamedPointerReinterpret(view{}). -
Labeled switches declare their break target (
break_BigSwitch:;after the switch — both switch visitors now mirror visitForStmt, CS0159). Guarded bySwitchBreakInCase(pick). -
Short declarations keep the named-numeric cast (
p := printFlags(0)re-imposes((printFlags)0), CS1503) and empty-interface switch tags compare via AreEqual (switch err := recover(); err { case ErrLarge: }, CS0019).
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.
- The Go 1.20 value form
[4]byte(slice)emitsnew array<byte>(s, 4)— the COPY constructorarray<T>(slice<T>, nint). Go’s conversion copies, so this is exactly faithful (netipAddrFromSlice, CS1955). - The Go 1.17 pointer form
(*[4]byte)(slice)emitsᏑ(array<byte>.Alias(s, 4))— an ALIASING window over the slice’s own backing store. The Go spec is explicit that here “the slice and array share their underlying array”, so the array is a view, andarray<T>carries a(low, length)window for exactly this one construction; every other construction spans its whole backing, where the window is inert.
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:
-
array<T>.Clone()— Go’s by-value array copy — materializes the window, so a copy is a full, offset-free array again; -
p[:]bounds its capacity at the array’s end (cap4, not the rest of the slice); -
&(*[4]byte)(s)[0] == &s[0]:ж<T>.CanonicalElementresolves an element pointer taken through a window to the same absolute element as one taken through the slice.
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:
-
Same element type only.
array<T>.AliasPointerwindows the pointer’s ownT[]backing; aT[]view over differently-typed storage does not exist in C#.(*[2]byte)(unsafe.Pointer(&x.Port))over auint16keeps the address route — that is the raw-metal fork, unchanged. - The window is decided at RUNTIME. Only a pointer that actually addresses managed array/slice storage has a backing to window; a heap box, a struct field or a native address falls back to the identical address route inside golib, so nothing that worked before changes shape.
-
Nis CLAMPED to the storage that exists. Go’sNin this idiom is a promise, not a length — it is spelled10000/1<<16/0xffffover buffers a few elements long and the result is always immediately re-sliced to the real count. Honoring it literally would put the window’s own bounds past its backing store, where a full-slice expression is an exception rather than a window. Solen(*p)reports the addressable extent, not Go’sN, in the huge-Ncase; an overrun is then a Go-style index panic instead of the silent corruption the same overrun is in Go. (WhereNfits — the ordinary(*[4]byte)(unsafe.Pointer(&s[i]))—lenis exactly Go’s.)
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 parameter
— sync.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):
-
No arm yields a C# type at all — a body terminated by
panic(func() any { calls++; panic("x") }emits a statement lambda whose only exit is athrow), or one whose sole arm is an untypednil(return default!). Inference has nothing to work from: CS0411 ×4. -
An arm’s NATURAL C# type differs from the declared Go result —
func() int { return 42 }is naturallyFunc<int>, where Go’sintisnint: the wrong delegate is inferred and the declaration it initializes rejects it (CS0029).
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.)
Promoted methods through a FOREIGN pointer embed forward via the embedded box
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.)
Promoted methods through embedded INTERFACE fields route per-member to the declaring field
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.Write → writeFile). 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.Write →
checkValid); 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:
- the converter’s own preamble for a direct-ж receiver (
visitFuncDecl, unconditionally fori == 0 && Recv != nil && directBoxReceiver; plus a repointed receiver’s re-alias invisitAssignStmt, so the two halves of one alias cannot disagree); -
go2cs-gen’s
ReceiverMethodTemplate, the bridge that reaches aref T-receiver method through a box. Left eager, that bridge panicked one call frame earlier than Go — theValAnnouncearm of the guard below caught exactly this and was the reason the generator half was found at all.
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 netip → unique 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 renew — p = 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]uint16 → array<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 nested — o.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 statement — keep: 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 (_, rᴛ1) in s) {
var r = rᴛ1; // 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 (_, vᴛ1) in tests) {
var test = vᴛ1; // 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 iᴛ1 = 0; iᴛ1 < 3; iᴛ1++) {
var i = iᴛ1; // 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 iᴛ1 = 0; iᴛ1 < 6; iᴛ1++) {
var i = iᴛ1;
if (i % 2 == 0) {
iᴛ1 = i; // an unlabeled continue copies back at its own site
continue;
}
i++;
fs = append(fs, () => i);
iᴛ1 = 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 iᴛ1 = 0; iᴛ1 < 3; iᴛ1++) {
ref var i = ref heap<nint>(out var Ꮡi);
i = iᴛ1;
ps = append(ps, Ꮡi); // three DISTINCT pointers: 0 1 2
iᴛ1 = 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 T — append<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 exprᴛ1 = etyp.Kind();
if (exprᴛ1 == 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:
-
slice<T>reachesIEnumerable<(nint, T)>throughISlice<T>→IArray<T>, which the old public method satisfied implicitly. The interface member is now an explicit implementation returning the same struct boxed — so LINQ, an interface-typed local, and anything holding the slice asIEnumerable<(nint, T)>behave exactly as before, at exactly the cost they already paid. Only the pattern path is free. - go2cs-gen’s
ISliceTypeTemplate(everytype S []Enamed-slice wrapper) forwarded the interface. It now forwardsglobal::go.slice<E>.Enumeratorand carries the same explicit interface member, so a named slice type ranges as cheaply as theslice<E>it wraps — otherwise everyfor rangeover a named slice would have kept the box.
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<CrossPkgLibꓸStatus, nint> CheckFunc = (CrossPkgLibꓸStatus st) => st.Code * 2;
internal static (CrossPkgLibꓸStatus, nint) gauge(CrossPkgLibꓸStatus st) {
internal static ж<CrossPkgLibꓸStatus> statusPtr(ж<CrossPkgLibꓸStatus> Ꮡ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:
-
The alias TARGET carries the target package’s OWN collision rename.
internal/reflectlite’stype Kind = abi.Kindpublishesgo.@internal.abi_package.ΔKind, becauseinternal/abiΔ-renamesKindagainst(*Type).Kind(). In a full run that Δ arrives fromabi’s parsedpackage_info.cs(theimportedTypeAliasesconsult inconvertToCSFullTypeName’s default arm); the derivation recomputes it with the same foreign-package-awarepackageHasMethodNamedtest the collision lane uses, so the two sources agree. -
A Go type ALIAS is exempt from the collision lane.
performNameCollisionAnalysisrecords only defined types (!typeSpec.Assign.IsValid()), so an alias name that also names a method is not Δ-renamed —reflectlitedeclares bothtype Kind = abi.Kindand(*rtype).Kind()and still publishes the plain source nameKind. The two derivations split on exactly that line: a colliding defined type belongs to the collision lane (which owns theToken/ΔToken/objecttwo-hop), a colliding alias to this one. Getting that boundary wrong either double-publishes one source name with two targets or dropsreflectlite’s entry entirely. -
An empty-interface target is
object, imported BARE.type PublicKey anyis not an alias at all but a defined type over the empty interface, which has exactly that interface’s method set and so takes the using-alias arm too. Its target is the C# keyword, not a package member (isCSharpBuiltinTypeName) —crypto’sPublicKey/PrivateKey/DecrypterOpts,plugin’sSymbol,database/sql/driver’sValue.
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 ΔInfo — go/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 renamedlibꓸMarker(Name: "alpha"u8, Size: 3); // composite literal (was: new Marker(…))
var (got, ok) = Describe(deltaˢ, 9)._<renamedlibꓸMarker>(ᐧ); // 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.Stdout → internal/poll → syscall.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:
-
TypeGenerator— driven by[GoType]. Emits the body of a converted type: a struct’s members and equality, a named numeric/slice/array/map/channel type’s wrapper and operators (see Named Numeric Types and Constant Contexts and Slices and Arrays), and struct-embedding field/method promotion. -
ImplementGenerator— wires up Go’s duck-typed interfaces: finds the concrete types that satisfy each[GoType] partial interfaceand emits the implementation glue and implicit conversions. -
RecvGenerator— emits pointer-receiver overloads for receiver methods ([GoRecv]), so a method written against a value (this ref T) is also callable through the pointer/box form. A variadic method keeps itsparamsin the generated overload: cryptobyte’sfunc (b *Builder) add(bytes ...byte)emits the value formadd(this ref Builder b, params Span<byte> bytesʗp), but theж<Builder>overload had droppedparams(a bareSpan<byte>), so a call passing individual elements through a box (c.add(0xff),caж<Builder>closure parameter) could not bind it and fell back to the ref-receiver value method — CS1929.GetMethodInfonow preserves theparamsmodifier (the Go variadic is always the last, non-receiver parameter, so it never lands on thethis ж<T>receiver). Guarded byVariadicBoxReceiver(a*sinkwithadd(bytes ...byte)called on a box — via a closure and directly — with zero, one, several, and spread arguments, values vs Go). -
ImplicitConvGenerator— emits the implicit conversion operators that let a named type and its underlying types be used interchangeably. -
PartialStubGenerator— emits a throwingpartialimplementation for any bodylesspartialmethod that has no other implementing part (e.g. assembly/cgo functions with no convertible body), while leaving real hand-written companion implementations untouched.
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 (GetScope — public 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:
-
The rule is
TypeGenerator’s, mirrored exactly.recordTypeAccessibilityuses the explicit modifier the converter emitted inline (the publicization pre-pass’spublic) when there is one, and otherwisegeneratedTypeScope, a Go-side mirror ofCommon.GetScope. Deliberately notgetAccess:GetScopereads the C# identifier verbatim, so aΔcollision-rename (a Greek capital) reads as exported wheregetAccessstrips the prefix first. Mirroring the generator is what keeps the corpus’s effective accessibility byte-for-byte unchanged and avoidsCS0262between the two parts. -
Generic types repeat the type-parameter list, never the constraints (
internal partial struct entry<K, V> {}) — a partial declaration may leave constraints to another part. An arity-0 constraint interface repeats the CRTP<ΔT>marker list the inline declaration carries. -
Every partial-emitting kind is covered: struct (defined struct, map, channel, slice/array, defined-over-selector), class (named pointer types), interface. A named func type is emitted as a C#
delegate, which is not partial and already carries its own modifier, so it has no entry. -
Hand-owned files are deliberately excluded. A file whose destination
.cscarries[module: go.GoManualConversion]has its converter output redirected to the non-compiled.cs.autosibling, so the declarations that actually compile are the author’s — their kind, name and modifier are the author’s to choose, and a generated entry could contradict them (CS0261/CS0262) or conjure a phantom empty type. Across the 302-package corpus that leaves 19 marked files, of which 12 declare[GoType]types (~48 declarations, mostlyruntime/runtime2.cs) — and exactly one interface among them,sync.Lockerin the hand-rewrittensync/mutex.cs. That single declaration is whyCommon.GetAllBaseInterfaces’sInaccessiblerecovery is RETAINED rather than retired: it is now a narrow backstop for hand-owned declarations instead of the primary mechanism. Writing the modifier explicitly on a hand-owned declaration removes the need for it there. -
Test variants get the same treatment, split by owning class. Under the recompile test model the production entries reach
package_test_info.csthrough the verbatim seed frompackage_info.cs, the internal variant’s test-local types merge into that same (production-class) section, and the external variant’s types — which live in<pkg>_test_package— go topackage_info_external_test.cs, whose first and only class is the test class.writeExternalVariantMetadataclears the accessibility set before writingpackage_test_info.csso a test-class type can never land in the production class, and the unit is now written whenever the external variant declares types, not only when it records test-anchoredGoImplement/GoImplicitConvattributes. Under ordinary reference the test class is the anchor; under white-box reference the external test class is preferred, with the internal bridge used when no external variant exists. -
The generator still writes the modifier on its own part — the section is a third part with the same modifier, which C# accepts. Removing it is not safe while the section deliberately does not cover hand-owned declarations: those parts would become
private. -
Generator-side consequence: a converted type now has two converter-written parts, so any lookup that resolves a type by name through the syntax trees can land on either.
Compilation.FindStructDeclarationandImplicitConvGenerator.GetStructDeclaration— both of which go on to read members or the[GoType]definition token — now prefer the part carrying[GoType](Common.IsGoTypeDefinition), falling back to first-match. Without that, syntax-tree order (i.e. compile-item order, wherepackage_info.cssorts ahead of the package sources in roughly half the corpus) would silently decide.
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 attributes and the access modifier travel together, by construction.
recordTypeAccessibilitytakes the stamps as an argument and returns what the caller must still write inline — empty when the record absorbed them. So the two paths that write no record keep the stamps on the declaration and cannot lose them: a hand-owned file (whose emission goes to the non-compiled.cs.autoreview sibling) and a-testsbridge unit (whose accessibility is inline because its metadata anchor can be a different test class). A hand-written conversion therefore still stamps[GoValueClone]inline, and it still works. -
TypeGeneratorreads the stamp across every partial declaration, starting with the[GoType]one its receiver matched and continuing through the symbol’s otherDeclaringSyntaxReferences. That is what makes both placements equivalent rather than one replacing the other. The match itself stays syntactic, as it is for every attribute this generator reads. -
The section sorts on the DECLARATION, not the line.
typeAccessibilityKeystrips the attribute prefix before comparing, so a stamped entry keeps the place its accessibility/kind/name earns instead of being pulled into a leading block by its[. Sorting the raw line is legal but scrambles a section whose whole value is being readable at a glance. -
Not a semantic change anywhere. The relocation moves where the attribute is written, exactly as the
TypeAccessibilitysection moved where the modifier is written. Nothing observes a difference: the generatedClone()is identical, andGoReflect’s%Toutput is identical.
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, TestTruncateRound → big.Int.Mul → mulAddVWW, 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:
-
purego-gated — the file is… && !puregoand a portable… || puregosibling exists (crypto/hashing hot paths:crypto/sha256,crypto/sha512,crypto/md5,crypto/sha1,crypto/aes,vendor/…/poly1305,vendor/…/chacha20poly1305,vendor/…/sha3,maphash, …). The tag selects the real body — no hand-owning needed. This is the large majority and the whole point of adopting purego. -
GOARCH-gated with no
puregoescape — the platform file is gated only on architecture (amd64 || arm64) with no|| puregoalternative, so the tag cannot reach a pure-Go variant (internal/chacha8rand’schacha8_amd64.s;hash/crc32’scrc32_amd64.go, which carries no//go:buildline at all — its only constraint is the_amd64filename suffix, sogo list -tags purego hash/crc32selects exactly the same files as the default build). These are hand-owned: either as a<name>_impl.cscompanion or as a whole-file rewrite (the mechanisms in Manually-Converted Declarations below). Hand-owning does not have to mean stubbing — where .NET exposes the same instructions the.sfile issues, the arch layer can be realized for real; see Realizing an asm-backed arch layer with managed hardware intrinsics. -
Genuinely raw-metal — memory-layout math, type-descriptor walking, or
*.asmwith no portable Go form at all (internal/abi,internal/bytealg, parts ofruntime). These are stubbed with[module: GoManualConversion]— a stub that compiles is an acceptable milestone solution and the operational body lands with the futuregetg()/thread-context model.
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:
-
Whole-file (pre-existing): a hand-finished file marked
[module: GoManualConversion]is never overwritten by the converter when it exists in place (containsManualConversionMarker), and is restored over auto output by the overlay on fresh (unseeded) reconversions. Right when the whole file is hand-owned (sync/atomictype.cs). The marked file’s Go source is NOT dropped from conversion (2026-07-17 fix): it is still analyzed and visited with its package — its anonymous-struct lifts, package-var registrations, and other package-wide state must keep feeding the package’s sibling files, or those emit corrupted (raw Gostruct{…}text in selectors, package-var assignments re-declared as shadowing locals) — with emission redirected to a non-compiled<name>.cs.autoreview sibling. Guarded by theManualConversionSiblingStatebehavioral test. -
Type-level (
go2cs/manualTypeOperations.go): themanualConversionTypes/manualConversionFuncsregistry (keyed by package path and raw Go names) makes the converter skip emitting the listed type declarations, every method on those types, listed adjacent free functions (setGNoWB), andGoImplicitConvassembly attributes referencing the types — each replaced by a marker comment pointing at the package’s*_impl.cs. Right when the types live in a large file (runtime2.go) that must otherwise keep receiving converter improvements. EachmanualConversionFuncsentry also carries a platform scope — see Hand-owns have a platform below.
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.Pointer — guintptr(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 (netpollinit → stdcall4(_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:
-
One Monitor, not Go’s lock-free
rg/wgCAS protocol. Go splitspollDescstate across atomics becausenetpollcheckerrruns where the pd lock cannot be taken andnetpollblockparks throughgopark’s publication protocol. Neither constraint survives the boundary — every managed caller is an ordinary blocked thread — and the single-waiter-per-mode contract (“pollDesc can hold only a single waiting goroutine for each mode”, enforced above byfdMutex) bounds contention to one reader + one writer + one timer callback. The CAS choreography would be emulation of a mechanism whose reason evaporated, the same reductionruntime_sema_impl.csmade for the runtime semaphore. -
Completion delivery is
ThreadPoolBoundHandle— the CLR’s own IOCP, so there is no poller thread of ours, no shutdown story, and no raw-address routing table.isPollServerDescriptortherefore answers false for every fd: there is no exposed poll-server descriptor, and its only consumer is the test-onlyIsPollDescriptor. The association is made lazily, at the first submit — not atpollOpen— and that placement is load-bearing rather than incidental. Go’s poller rejects completions it does not own (pollOperationFromOverlappedEntrycompares the completion key against thepollDescrecorded in the operation and drops a mismatch, go.dev/issue/58870);ThreadPoolBoundHandlehas no equivalent, because its callback resolves state from theNativeOverlappedit allocated, so a foreign overlapped is misread rather than ignored. Binding at open would make every pollable socket eligible for that, andinternal/pollhas a live producer:FD.WSAIoctlbypassesexecIOand hands the kernel the caller’ssyscall.Overlapped— which, being an all-scalar struct in a standard box, genuinely pins and genuinely reaches the kernel. Binding at first submit removes the hazard structurally: a socket that only ever sees foreign overlapped IO is never associated at all. (Both productionFD.WSAIoctlcallers pass aniloverlapped, so no corpus path mixes the two on one socket.) -
The load-bearing separation is wake-vs-readiness. A completion sets
readyand pulses; a deadline firing andpollUnblockpulse without setting it. Go encodes this aspdReady-vs-pdNil-wake, and it is what makeswaitCanceled’s ignore-errors loop correct — that loop waits for a COMPLETION specifically, ignoring the very timeout that woke its caller. Readiness is also consumed before either error check, so a completion that raced the deadline is still delivered. -
Deadlines are
Timer+ sticky flags + generations, notCancellationToken. A CTS models a one-shot cancellation of a linked operation; a Go deadline is per-MODE, STICKY across future operations until re-set, REPLACEABLE while an op is in flight, and NON-ABANDONING (the timed-out op must still be cancelled and harvested by the same caller). Modeling all four with tokens reconstructs the flags+timer state machine anyway. The generation check is not optional hardening:Timer.Change/Disposedo not synchronize with an in-flight callback, so without it a cleared deadline can be expired by the callback of the deadline it replaced.
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).
-
Where a hand-owned FILE is built is layout L3’s question, answered by the file’s principal: an
*_impl.cstakes part in exactly the platform builds the<name>.csit supplements does; a marked whole-file hand-own follows its.cs.autoreview sibling. Every platform ⟹ flat; a subset ⟹ one copy per platform in the subset. Routed automatically by the reconvert merge and guarded by a walk of the real corpus (platformHandOwn_test.go). -
Whether a DECLARATION is hand-owned at all is the registry’s question, and until 2026-08-08 it had no platform axis:
manualConversionFuncswas keyed by name alone. A Go name is not unique across platforms — Go selects one of several files declaring the same function by build constraint — so one entry spoke for every flavor, turning each one’s declaration into a placeholder while an implementation existed only where somebody had written one.
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: unpackEface → abi.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 allocs — crypto/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 |
fixed — ToSlice extension |
len(s) on the same |
48 B each | the len<T>(IByteSeq<T>) overload took an interface parameter |
fixed — len<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.GoMethodCount →
TypeExtensions.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 caller — reflect.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 INSTANCE —
Delegate.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:
-
The func type comes from the method TABLE, not from a delegate instance.
reflect.Type.Method(i).Typeis synthesized invalue_impl.csfromGoMethodFuncTypeover theMethodInfo’s parameters, and nothing in that path ever holds a delegate forGoReflect.FuncParamDimsto read.GoReflect.MethodParamDims(t, i)reads the same[GoArrayDims]stamps straight off thoseParameterInfos, andMethod(i)carries them as the descriptor’sfuncParamDims. 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 withIn(i)by construction — which is Go’s own shape for a method type, receiver first. -
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 throughElem()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 tovisitFuncDecl’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]Tparameter never reachesgenerateParametersSignatureat 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 run — In(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 (DeferTypelessReturns’ first) 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
-
Low) pairs in a reference-identity set, added before recursing so in-progress checks are assumed true — self-referential structures terminate.
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.X86 — Sse42.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:
-
Probe capabilities LOCALLY; never flip
internal/cpu’s global flags. The Go guards readcpu.X86.HasSSE42/HasPCLMULQDQ/HasSSE41. Those flags are shared by every converted package’s arch path, and the rest of those arch layers are still throwing stubs — setting them centrally would trade each package’s working portable fallback for aNotImplementedException. The hand-owned file instead defines its own predicates over the.IsSupportedproperties of exactly the instruction sets it uses (Sse42.X64.IsSupported;Pclmulqdq.IsSupported && Sse41.IsSupported) andarchAvailable*returns those. The claim then stays precisely true — “these instructions are available to this code” — andinternal/cpuis untouched. When a probe is false,crc32.go’s ownarchAvailable*branch falls back to slicing-by-8, which is exactly how Go degrades on an architecture with no arch implementation. -
Port the
.sfile, label for label, and say so. The fold constants are the.sDATApairs verbatim; a 128-bit load puts the offset-0 quadword in the low half, soVector128.Create(offset0, offset8)reproduces each register image exactly.PCLMULQDQ’simm8maps directly ontoCarrylessMultiply’scontrol(bit 0 selects the left operand’s quadword, bit 4 the right’s), soPCLMULQDQ $0x11, X0, X1isCarrylessMultiply(x1, x0, 0x11). Each ported block carries the.slabel it transcribes (aligned,less_than_8,loopback64,remain64,remain16,finish) so the two can be diffed by eye at upgrade time. -
Divergences are deliberate, documented in place, and result-neutral. The
.sfile walks the buffer to an 8-byte boundary before itsCRC32Qloop; the managed port drops that step. It exists to align the loads, it cannot change the answer (CRC is a pure function of the initial value and the byte sequence, and theCRC(I, ABC)combining identity holds for any split), and it has no managed counterpart — a slice’s backing array can be moved by the GC, so an address observed here is not an address the loads keep. Likewise the.striple loop is aDECQ/JNZdo-while that would wrap onrounds == 0; the managed form is a counted loop, identical for every real input.
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 (linknameForwardTargets — syscall.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:
-
The accessibility half is no longer a hand-own’s responsibility.
packageFuncAccessemits a package-level free functionpublicwhen it carries a one-arg linkname handle and its<pkgpath>.<name>is a forward target. The handle alone is deliberately not enough — Go 1.23 carries 340 of them outsidecmd/, and publicizing every one would widen the corpus’s whole surface for pulls that are never emitted. The whitelist is the converter’s own record of which pulls actually become a call, so gating on it moves exactly the symbols that need to move. -
The forwarder must be able to name a package the file never imports. The pull queues the target’s
path for a project reference (as the var-pull arm already did) and, when the file carries no import
spec for it, emits the call fully qualified —
go.time_package.registerLoadFromEmbeddedTZData(…), which resolves insidenamespace go;with no alias. A baretime.would have bound thego.timeCHILD namespace — tzdata’s own — rather than the package class (CS0234).
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