Baseline vs. Full Conversion — the separation contract
Companion to /CLAUDE.md. Defines what lives where, why, and the rules that keep the
converter-improvement loop and the full-stdlib goal from colliding again.
The three things
-
Baseline stdlib —
src/core/<pkg>Small, hand-finished, compiling subset of the Go standard library. This is what the behavioral tests and converter-improvement loop build against. It must always stay green. -
Full auto-conversion —
src/go-src-converted/(target location) The entire Go standard library (302 packages, Go 1.23.1) auto-converted bygo2cs -stdlib. The ultimate goal — and as of 2026-07-10 all 302 packages compile clean (commit51ba5d9cf, tagstdlib-green-2026-07-10; the Phase-3 milestone). Compiling, not yet operational — running Go’s own package tests is Phase 4. -
Runtime —
src/core/golib/Hand-written C# runtime (slice,map,channel,@string,builtin,ж<T>, type aliases). Shared by both baseline and full conversion. Never auto-overwritten — some of it (builtin,unsafehelpers, assembly-backed routines) can never be produced by transpilation.
Why they must stay separate
Both baseline and full emit into namespace go with <pkg>_package static partial classes. Referencing
both from one C# project produces duplicate-type collisions. So they are kept in separate directories
and never referenced together by a single project.
How the collision happened (history)
| Commit | Date | Event |
|---|---|---|
9792eeea2 |
2020-07-09 | Hand-converted stub created at src/gocore/<pkg> (Tour-of-Go support). |
| (many) | 2020–2025 | Stub maintained/refined for years; it was the working library. |
ba6fef6c9 |
2025-03-08 | src/gocore renamed → src/core (path change only). |
3426298eb |
2025-05-05 01:51 | Last clean baseline. Stub compiles; tests green. |
6ca1c45b7 |
2025-05-05 01:59 | “Initial standard library conversion” — full stdlib written on top of src/core, overwriting the hand-finished packages (2,359 files, +508k lines). |
cc14584c7 |
2025-05-11 | Full-conversion work; tagged full-conversion-2025-05. |
| 2026-06-25 | 2026-06-25 | Separation restored: full conversion relocated to src/go-src-converted/; old stub restored into src/core; converter fixes; green baseline. |
The mistake was writing the full conversion into the same directory as the baseline instead of a
separate one. “All 305 packages converted successfully” meant the transpiler did not crash — not that
the emitted C# compiles. The overwrite replaced compiling fmt/time/etc. with large machine-generated
versions, which stalled the test loop.
The project was originally designed with this separation (gocore manual subset + go-src-converted
full auto-output), so restoring it realigns with the original design.
How it was resolved (2026-06-25)
- Relocated the full conversion out of
src/coreintosrc/go-src-converted/(a 2604-file git rename); rewrote inter-packagecsprojrefs andgo2cs.slnpaths; added.gitignorerules for the Godebug/logpackages that collide with the VS[Dd]ebug//[Ll]og/patterns. - Restored the old hand-finished stub from
3426298ebintosrc/core. Key finding: it compiles cleanly against today’sgolib— the feared API drift did not materialize, so it gave a green baseline immediately. Restored 14 packages; excluded the stubtesting(drifted, 400 errors, referenced by no test). - Scoped
src/go2cs.slnto the baseline + tests; addedsrc/go-src-converted.slnxfor the 301 WIP projects. - Result:
go2cs.slnbuilds 79/79; behavioral suite green (216 tests).
The contract (rules going forward)
src/core/<pkg>is curated and must compile. Treat it as hand-owned source. Do not bulk-overwrite it with-stdliboutput.src/go-src-converted/is the full-conversion target. Allgo2cs -stdlibruns write here via-go2cspath. It may be regenerated wholesale; nothing hand-edited lives here long-term (fixes belong in the converter or, for out-of-band pieces, ingolib).golibis shared and never auto-generated. Both trees referencesrc/core/golib/golib.csproj.- Promotion
go-src-converted → coreis DEFERRED (strategy correction, 2026-07-01). Earlier work promoted packages intocoreas they went green (compiling). That was premature — compiling is not operating. Promotion should happen only once a package’s converted Go unit tests pass (Phase 4), and may not be needed at all (see The corrected end-state below). Until then,corestays the small bootstrap stub the behavioral tests build against (chicken-and-egg — the tests need a working library to run, andgo-src-convertedcompiling doesn’t yet mean it works).sync/atomicalready living incoreis fine — it remains a useful stub. Do not promote further on the basis of a clean compile. The converter is never pointed at the baseline directory. - The canonical MANUAL files live in
coreand are copied BACK intogo-src-converted. Files marked[module: GoManualConversion](the converter skips re-converting them) and hand-written*_impl.csfiles are hand-owned insrc/core/<pkg>. For a full-conversion milestone to be complete, these must be overlaid into their matchingsrc/go-src-converted/<pkg>locations — that overlaid tree (auto-output- manual/asm stubs) is the real final state.
overlay.shalready re-copies thesrc/coremanual files after the cs/csproj copy; during these final compiling stages, do this religiously. The overlay must also copy the<name>.cs.autoreview siblings the reconvert produces (a bare*.csglob misses them) — and a reconvert only produces them when its output dir was seeded with the marked hand-owned files first, since the marker gate probes the destination.cs(see Hand-owning a package… below). Seeding is safe (2026-07-17 fix): a marked file is still analyzed and visited with its package — only its emission is redirected to the sibling — so every unmarked file of a seeded reconvert emits byte-identical to an unseeded run.
- manual/asm stubs) is the real final state.
The corrected end-state (2026-07-01) — compile first, operate later
The milestone is a clean C# COMPILE of the whole overlaid go-src-converted (auto-output + the
manual/*_impl.cs/asm stubs) — not an operational one. Operational correctness is Phase 4 (converting +
passing the Go unit tests). Getting there, for runtime:
- Native-type pointer/unsafe ops are convertible. Go and C# are both GC languages with pinning and
unsafe pointers; native types share identical memory operations. Pointer parity for native types is the
goal and is achievable (the hand-converted
unsafe/sync/atomiccode proves the overlap). Fix these in the converter/golibproperly. - Managed-referent cases have a known model. Where Go stashes a managed pointer inside a
uintptr(guintptr/muintptr/puintptr…) to hide it from the GC, the C# equivalent holds theж<T>/objectdirectly (Volatile/Interlocked +nilCanon), never anuintround-trip — exactly ascore/sync/atomic/type.cs’satomic.Pointer<T>andreflectlite/value.cs’sobject? m_targetdo. A rawuintptrcannot hold a managed reference across a GC (the “compiles-but-crashes” trap). - Raw-metal on NON-native types is the dragon — stub it. Memory-layout math, type-descriptor
pointer-walking, and
*.asmcannot be faithfully transpiled. When the loop hits this wall, the file gets an immediate[module: GoManualConversion]task / review — a hand-written C# equivalent, or a throwing stub that won’t exist in the final build — not a converter fight. AGoManualConversionstub that makes the package COMPILE is an acceptable milestone solution; the faithful hand/asm implementation can follow.
So the loop no longer stops at the S1/CS0030 “architectural wall” — it sorts: convert the native-type
ops, apply the managed-referent model, and stub the genuine raw-metal dragons with GoManualConversion.
Once the whole stdlib compiles and the converted Go tests pass, a versioned build can ship to NuGet;
at that point the chicken-and-egg is gone and core can be dropped (behavioral tests reference NuGet) or
replaced with prior operational go-src-converted source — TBD.
Hand-owning a package to make it OPERATIONAL (Phase 4) — two patterns + the marker
Phase 3 stubbed the raw-metal dragons just to compile. Phase 4 (making packages run) needs the opposite
in places: a faithful native reimplementation where the literal Go→C# conversion can compile but cannot
work. The canonical case is sync (2026-07-11): its concurrency types are a state machine over the Go
runtime sleeping semaphore (//go:linkname Semacquire/Semrelease/notifyList/…), which is
co-designed with the mutex (starvation-mode ownership handed to one specific waiter via an exact ticket) and
cannot be emulated on any .NET primitive — every emulation deterministically trips sync: inconsistent
mutex state / unlock of unlocked mutex under sustained contention. The fix is to reimplement the types
natively on proven .NET primitives (Mutex→binary SemaphoreSlim, WaitGroup→counter+latch,
RWMutex→writer-preferring monitor lock). Expect more of this in Phase 4 (time, parts of os/syscall, …).
There are two ways a package carries hand-owned C#, and they are NOT interchangeable:
-
*_impl.cssupplement — for SOME declarations in a file. The converter emits the file normally but, for types/funcs listed inmanualConversionTypes/manualConversionFuncs(manualTypeOperations.go), replaces the body with a// … hand-converted … see the package's *_impl.cscomment and a bodylesspartial. A hand-written<name>_impl.cscompanion (no matching.go, so a reconvert never touches it) supplies the real bodies. Use when only part of a converted file needs managed semantics (e.g.sync/atomic,runtime/lock_sema). The*_impl.csfile typically also carries[module: GoManualConversion]for documentation, but does not need it (nothing regenerates it). -
Whole-file replacement — for an ENTIRE file, and it REQUIRES the marker. When the whole
<name>.csis hand-written (replacing the converted<name>.gooutput — e.g. sync’smutex.cs/waitgroup.cs/rwmutex.cs), it MUST carry[module: GoManualConversion], or a-stdlibreconvert regenerates the Go version straight over it.main.go’s conversion loop callscontainsManualConversionMarker(<output>.cs)for each.gofile and drops that file from the conversion set when the marker is present (directiveOperations.go). This is the ONLY thing that makes a whole-file native reimplementation durable across reconverts.Complete inventory of whole-file replacements (every non-
*_implfile carrying a real module-level marker insrc/go-src-converted; grep-verified 2026-07-16): syncmutex.cs/waitgroup.cs/rwmutex.cs/pool.cs; runtimeruntime2.cs/mfinal.cs; syscalldll_windows.cs/exec_windows.cs(2026-07-19 —StartProcessonly; see Child-process creation below); internal/godebuggodebug.cs(2026-07-17, blocker R2 — parses $GODEBUG once on first use instead of the Go runtime’s update-hook cache; the literal conversion’s embedded*settingpromotion faults at runtime because the generated promoted-field box treats its held nil pointer as a nil dereference even for the assignment that would populate it; subsumed and removed the oldergodebug_impl.cshook companion) (these live ingo-src-convertedonly); sync/atomictype.cs/value.csand unsafeunsafe.cs(canonical insrc/core, byte-identical copies ingo-src-converted); mathunsafe.cs(2026-07-16 — Float32/64 bits/frombits as directBitConverterbit-cast intrinsics, replacing the literal conversion’sж<T>/uintptrround-trip that compiles but cannot reinterpret bits at runtime; canonical insrc/core/math, byte-identical copy ingo-src-converted/math; guarded by theMathFloatBitsbehavioral test). Several*_impl.cscompanions also carry the marker (documentation only, per pattern 1): internal/abitype_impl.cs, reflectvalue_impl.cs, runtimelock_sema_impl.cs/runtime2_impl.cs, syncruntime_impl.cs, internal/pollruntime_sema_impl.cs, syscallsyscall_impl.cs(2026-07-19), and math/rand + math/rand/v2rand_impl.cs(2026-07-17, blocker R3 —runtime.randlinkname bodies onRandom.Shared: OS-entropy seeded, thread-safe, non-deterministic run to run exactly like Go’s runtime generator; the os / net / hash/maphashruntime_randdeclarations still carry throwing stubs).
Marker mechanics: [AttributeTargets.Module, AllowMultiple = true] (golib GoManualConversionAttribute), so
one per file across a package is fine. The scanner wants it before the first class, so place it after the
usings and before the file-scoped namespace, written [module: go.GoManualConversion] (fully qualified so
it resolves without a using go;). Verify a whole-file override survives by reconverting the package into
a dir seeded with the hand-written file and confirming it stays byte-identical
(go2cs -stdlib -go2cspath <seeded-root> <pkg> → the marked .cs is untouched).
Upgrade-time review — the <name>.cs.auto sibling (2026-07-16; emission model corrected 2026-07-17). A
marker-skipped file would otherwise leave NO auto-converted output at all, so a Go-version upgrade would have
nothing to diff the hand-owned C# against. The converter therefore emits a non-compiled <name>.cs.auto
sibling beside every marked <name>.cs — the converter’s best-effort auto conversion of the same .go, for
review only. It need not compile and is invisible to the build: generated csprojs compile
<Compile Include="*.cs" /> only, which cannot match a name ending in .auto.
How it is emitted matters (2026-07-17 defect fix). A marked file is NOT dropped from the conversion
pipeline: it stays in the convert set and is analyzed and visited with the package, in normal file order,
so every piece of package-wide emission state its declarations feed — anonymous-struct lifts, package-var
registrations, escape/addressed-global analysis, imports, init/temp-var numbering — reaches the package’s
other files exactly as in an unseeded conversion; only the file’s WRITE target is redirected from <name>.cs
to <name>.cs.auto (main.go’s file-visit loop). The original implementation instead skipped the marked
file’s entire visit and emitted siblings in a separate last pass (emitAutoConversionSiblings), which
corrupted every OTHER file of a seeded package: runtime’s proc.cs emitted raw Go struct{…} text where
schedt’s lifted anonymous-struct type names belong (unparseable C#, a CS1513/CS1022 cascade) and re-declared
the newprocs = 0 package-var assignment as a shadowing local var newprocs = 0;, because runtime2.go’s
state contributions never registered. With the fix, a seeded reconvert is byte-identical to an unseeded
one for every unmarked file — plus the marked .cs left untouched and the .cs.auto siblings added.
Guarded by the ManualConversionSiblingState behavioral test (a marked state.cs whose skipped state.go
declares an anonymous-struct-field type and package vars; the sibling main.cs consumes both).
emitAutoConversionSiblings (src/go2cs/autoSiblingOperations.go) remains only for FULLY hand-owned
packages, where the normal conversion path is skipped outright (no unmarked files, and no .csproj /
package_info.cs / package_init.cs regeneration).
Siblings are committed in src/go-src-converted/<pkg> and refreshed by reconverts — the gate probes the
DESTINATION .cs, so a reconvert only produces them when its output dir is seeded with the marked files
first (see §5 above); with the fix that seeding is safe. *_impl.cs companions have no matching .go, so
they get no sibling; unsafe is never queued by -stdlib (compiler-intrinsic, stdLibConverter.go), so
unsafe/unsafe.cs has none either. Single-file conversion mode (go2cs example.go) emits no siblings — the
marker gate is not effective there to begin with.
The rule from §5 still holds: canonical hand-owned files live under src/core/<pkg> and are overlaid into
src/go-src-converted/<pkg> (overlay.sh) — with the marker, an overlaid whole-file override then survives
the next reconvert instead of being clobbered.
Child-process creation (os/exec) — what had to be hand-owned, and what did not
Re-executing the current binary is how os, os/exec, runtime, flag, log and a large number of
stdlib test suites exercise subprocess behavior, so this path gates a lot of Phase 4. Four distinct
layers were broken; only one of them warranted hand-owning.
- golib — the reinterpret seam boxed a copy instead of aliasing the address, so
os.Environ()walked the GC heap and freed it (STATUS_HEAP_CORRUPTION). Fixed generally inж<T>(see A reinterpreted raw address ALIASES native memory inConversionStrategies-Reference.md). - Converter —
unsafe.Pointer(p)on a pointer parameter dereferenced it, so the nil out-pointer inStartProcess’s deferredDuplicateHandlepanicked; and a switchdefault:clause could be emitted unchained, so every(*Process).waitreturned “os: unexpected result from WaitForSingleObject”. Both fixed in the converter with behavioral guards. - Bodyless runtime-provided partials —
syscall.Exit/Getpagesize/runtimeSetenv/runtimeUnsetenvandos.runtime_beforeExitare provided by Go’s runtime, so go2cs emits throwing stubs. Supplied as*_impl.cscompanions (pattern 1). Without them no converted program could exit deliberately, and a child reported the stub panic instead of its own status. syscall.StartProcess— the genuine hand-own (pattern 2). Go handsCreateProcessWa*_STARTUPINFOEXWwhose fields are pointers into native memory; the converted struct holds them as golibж<T>boxes — managed class references that are neither the right bytes at the right offsets nor marshalable at all (unsafe.Sizeof(*si)throws “cannot be marshaled as an unmanaged structure”). Same for_PROC_THREAD_ATTRIBUTE_LISTover anarray<byte>. This is the memory-layout / raw-metal case, soStartProcessis transcribed against blittable[StructLayout(LayoutKind.Sequential)]mirrors and direct P/Invokes. Every other declaration inexec_windows.csis the converted output verbatim — argument escaping, command-line building, environment-block building and path normalization are pure Go logic that converts faithfully — and the native code reuses those helpers plus the scalar-only converted wrappers (GetCurrentProcess,DuplicateHandle,CloseHandle).
Note what did not need hand-owning: SecurityAttributes is fully blittable (two uint32s and a
uintptr), so CreatePipe — and therefore os.Pipe, which CombinedOutput relies on — works through the
ordinary converted wrapper. The struct-passing seam only breaks for structs holding ж<T> fields.
The hand-owned implementation also copies every buffer handed to CreateProcessW (application name,
command line, environment block, working directory, handle list, attribute list) into unmanaged memory
for the duration of the call, freeing it in a finally. That closes the transient-pinned-address window
documented in dll_windows.cs, where golib’s ж→uintptr conversion can only produce an address a
compacting GC might invalidate mid-call.
Verified capability: a converted program re-executes itself, passes argv and a modified environment,
has stdout and stderr captured through CombinedOutput, waits, and surfaces the child’s exit code —
byte-identical to go run on the same program.
Known limits. Windows only (StartProcess is the Windows implementation; the POSIX
fork/exec path is untouched). Go’s SysProcAttr surface is honored — HideWindow, CmdLine,
CreationFlags, Token (via CreateProcessAsUserW), ProcessAttributes/ThreadAttributes,
NoInheritHandles, AdditionalInheritedHandles, ParentProcess — but only the plain
CreateProcessW/CreateProcessAsUserW paths are exercised by tests so far. syscall.Exec remains
EWINDOWS, as in Go. Signals and process groups are unaddressed; Process.Kill/Signal route through
the ordinary converted os code and were not exercised here.
Regenerating the full conversion
Current Go converter (authoritative flags in src/go2cs/main.go):
# Whole stdlib into the separate target:
go2cs -stdlib -comments -go2cspath <repo>/src/go-src-converted
# Specific packages only (used when greening a closure bottom-up):
go2cs -stdlib -comments -go2cspath <repo>/src/go-src-converted fmt strings io sort time
Always pass
-commentsfor stdlib conversion. It defaults off, but the converted C# is a derivative work — the per-file// Copyright … The Go Authors … BSD-style licenseheader must be preserved, and the Go doc-comments keep the output readable. Without the flag, headers and comments are stripped.
Package conversion is sequential (it relies on package-level converter state); output .csproj references are generated from detected imports.
Note on the
<go2cspath>/coresubdir: the stdlib converter writes packages to<go2cspath>/core/<pkg>(a hardcodedcoresubdir). To regenerate cleanly intosrc/go-src-convertedyou must either point-go2cspathso that subdir lands there, or convert to a temp dir and move. Don’t let it overwrite the baselinesrc/corepackages.
The old stub as a fallback / reference
The last clean stub (3426298eb) is the source of today’s baseline. To inspect or recover individual files:
git worktree add ../go2cs-stub-ref 3426298eb # browse the last clean baseline
git show 3426298eb:src/core/fmt/print.cs # or per file
Stale tooling
- Fixed:
src/deploy-core.bat(gocore→core);docs/README.md(banner + corrected references). - Still stale:
src/convert-gosrc.cmd/convert-gosrc.batinvoke a retirednet6.0C#go2cs.exewith old flags (-s -r -e -g); update to the Go converter’s-stdlib -go2cspath …form.