gum-cross-platform-unification
GitHub统一Gum跨平台运行时文件,通过条件编译合并MonoGame/Raylib/Skia等平台的重复代码,减少维护成本。
Trigger Scenarios
Install
npx skills add vchelaru/Gum --skill gum-cross-platform-unification -g -y
SKILL.md
Frontmatter
{
"name": "gum-cross-platform-unification",
"description": "Unifying per-platform runtime files (MonoGame\/Raylib\/Skia\/KNI\/FNA, plus the Apos.Shapes ↔ SkiaGum shape-runtime pair) into one source with"
}
Gum Cross-Platform Runtime Unification
The Pattern
Per-platform runtimes (e.g. ColoredRectangleRuntime, TextRuntime, ContainerRuntime) historically live as three separate files — one each in MonoGameGum/GueDeriving/, Runtimes/RaylibGum/GueDeriving/, and Runtimes/SkiaGum/GueDeriving/. Unification collapses them into one source file in MonoGameGum/GueDeriving/ with #if RAYLIB / #if SKIA / #if XNALIKE directives, then links that file into the Raylib and Skia csprojs via <Compile Include="..\..\MonoGameGum\GueDeriving\FooRuntime.cs" Link="GueDeriving\FooRuntime.cs" />.
Reference implementations: TextRuntime.cs (#2509, #2510) and ContainerRuntime.cs (#2511). Read those before doing a new one — they set the idioms for using aliasing (Color, renderable type), XNALIKE symbol, namespace switching.
Apos.Shapes ↔ SkiaGum shape runtime pair
Shape runtimes that exist on the Apos.Shapes side (MonoGameGumShapes / KniGumShapes) and SkiaGum — RoundedRectangleRuntime, ArcRuntime, ColoredCircleRuntime, LineRuntime — follow the same source-sharing pattern but with a different canonical home: Runtimes/SkiaGum/GueDeriving/ rather than MonoGameGum/GueDeriving/. The Apos csprojs file-link via <Compile Include="..\SkiaGum\GueDeriving\FooRuntime.cs" Link="GueDeriving\FooRuntime.cs" />.
Why a different home: these runtimes wrap Skia-specific renderables on one side and Apos.Shapes-specific renderables on the other. Neither platform's renderable surface aligns with the MonoGame/Raylib/Sokol axis, so putting the canonical file in MonoGameGum/GueDeriving/ would be misleading. Reference implementation: RoundedRectangleRuntime.cs. Platform divergence uses #if SKIA (no RAYLIB / XNALIKE involved on this pair).
Scope of this pair — do not over-generalize. This Apos↔Skia pairing covers only the Apos-specific runtimes (RoundedRectangleRuntime, ColoredCircleRuntime, ArcRuntime, LineRuntime). It is not a statement that shape support is MonoGame/Skia-only or that raylib lacks shapes. The general-purpose RectangleRuntime/CircleRuntime are unified on the normal MonoGameGum/GueDeriving/ axis (#if RAYLIB/SOKOL/SKIA/XNALIKE) and reach full filled/rounded/shadowed capability on every backend — raylib included (it wraps the fully-featured LineRectangle/LineCircle). See gum-runtime-topology "Shapes are NOT MonoGame/Skia-only" for the per-backend renderable table.
Don't Route Tests Around a Divergence You Find — Fix or Surface It
A per-platform #if-gated dispatch (e.g. CustomSetPropertyOnRenderable.cs's TrySetPropertyOn*Runtime methods) can silently drop a property for one backend: the gate skips it, reflection falls back to the low-level renderable, finds no matching member, and no-ops with no exception. Don't write a test that passes by asserting that fallback's default instead of the property actually applying — that hides the divergence. Fix the gate (see "Disagreements Are the Whole Job" below) or write a failing/pinned test that names the gap.
Disagreements Are the Whole Job
When three files diverge, every difference falls into one of two buckets:
- Platform-necessary divergence — the platforms genuinely can't do the same thing. Keep under
#if. Examples: XNABlendStatevs Raylib's absent blend state; Skia'sSKColorvs XNAColor; Raylib's lack of anAlphaproperty onSolidRectangle. - Historical inconsistency — one or more platforms drifted from the others, probably by accident. One of them is wrong and needs to be corrected, not preserved.
You cannot tell these apart by reading the code. The file doesn't know whether a difference is intentional. You have to ask.
Always-Ask Checklist
Before writing a single line of the unified file, diff these across all three platforms and surface every mismatch to the user with a recommendation:
- Base class.
GraphicalUiElementvsInteractiveGueis not cosmetic —InteractiveGueabsorbs pointer events viaHasEvents. Promoting a decorative runtime (ColoredRectangle, Sprite, NineSlice) toInteractiveGuesilently breaks click-through in every downstream project, with no compile error and no runtime error. HasEventsdefault. ExplicitHasEvents = true/false/ unset. Container sets it true on purpose. Most runtimes should leave it at the default (false).- Constructor defaults.
DefaultWidth,DefaultHeight,DefaultColor, initialWidth/Height, initialText,Font, etc. If one platform defaults to 50×50 and another to 0×0, that's a bug in one of them — decide which. - Renderable type. Skia ColoredRectangleRuntime uses
RoundedRectanglewhile MG/Raylib useSolidRectangle. Changing the renderable class affects draw order, batching, and clip behavior. Preserve per-platform unless the user explicitly signs off on unifying. - Property coverage. If platform A exposes
Alpha/BlendState/MaxLettersToShowand platform B doesn't, decide whether B should gain it (via the underlying renderable's capability, e.g.Color.Aon Raylib) or stay gated under#if. Clone()override. Some per-platform versions reset cached renderable fields, others don't. Missing aClone()override leaks a stalemContainedXpointer after cloning. Add it to all unified runtimes.AddToManagers()obsolete wrapper. Present on MG, often absent on Raylib/Skia. Should usually be added everywhere for API parity.NotifyPropertyChangedon setters. MG typically has it, Raylib/Skia often don't. Usually safe to add everywhere (binding/data-flow consumers benefit).
The Rule That Matters
Any disagreement on base class or HasEvents gets surfaced to the user with options before any code is written. These two silently change input behavior across every downstream project. They are never safe to "just pick one."
For other disagreements: pick a default, but state the disagreement and the chosen resolution in your message before writing the code — not after in a summary. If the user disagrees, you've lost two minutes, not a release.
Don't Redeclare a Property the Base Already Exposes
Before adding a "forward to the contained renderable" property to a runtime, check whether GraphicalUiElement (the base of every runtime) already exposes it. Several text properties — notably TextOverflowVerticalMode and MaxNumberOfLines — live on GraphicalUiElement with a backing field its layout coordination reads (e.g. RefreshTextOverflowVerticalMode, DialogBox page-splitting). Redeclaring such a property on the runtime hides the base one (CS0108) with a version that only writes the renderable and never touches the base field. Callers holding a GraphicalUiElement-typed reference still hit the base property, so the runtime's renderable and the base's field silently diverge and layout that depends on the field breaks — with no compile error. The inherited property already forwards to the renderable, so a redeclaration is redundant as well as harmful; a TextRuntime can set it with no cast. Only declare a forwarder for a property the base genuinely lacks (e.g. IsTruncatingWithEllipsisOnLastLine).
Changing a Renderable's Mechanism Strands Its Old Tests
When you swap how a renderable achieves an effect (e.g. Skia text outline moving between RichTextKit's native halo and a render-time recolor+dilate pass), grep the test projects for assertions against the old mechanism (Halo, the old GetStyle shape) — they compile fine and fail only at run time, and a per-OS CI job that fail-fasts hides which one broke. Run the whole affected test project locally, not a name-filtered subset.
SkiaGum's Topten.RichTextKit Is a Vendored Patched Build
Runtimes/SkiaGum/SkiaGum.csproj's Topten.RichTextKit reference is not the real nuget.org package — it's a local-feed build (ThirdParty/nuget-local/, wired via /NuGet.config) patched to fix Style.HaloWidth spiking at acute glyph corners. Don't "clean up" the version to the real 0.4.167 in a routine dependency-update pass — that silently reintroduces the spike. Upstream: toptensoftware/RichTextKit#113 (issue), #114 (fix). See ThirdParty/README.md for what's vendored and when to revert.
Runtimes/SilkNetGum/SilkNetGum.csproj has its own separate PackageReference for the same package — it links SkiaGum's Renderables/*.cs source via <Compile Include> rather than referencing SkiaGum.csproj, so bumping the vendored version in one csproj does not carry to the other. Bump or revert both together, or the shared Text.cs silently runs against unpatched behavior in whichever csproj was missed, with no compile error and no failing test to catch it.
Never Widen an Obsolete API
When a member gated under #if carries [Obsolete] (or is otherwise a deprecated back-compat shim) and a sibling runtime exposes it on more platforms, do not "fix the inconsistency" by widening the obsolete member to the missing platforms. Obsolete APIs are deprecated paths we want consumers off of — adding them to a backend that never had them plants a fresh dead surface in new code. Leave the gate at its current footprint and add a code comment explaining it is intentionally not widened. Two sibling runtimes disagreeing on which platforms carry an obsolete member (e.g. one gated #if !SKIA, another only #if XNALIKE) is not itself the bug — comment both as intentional rather than widening the narrower one to match. This is the one asymmetry the boyscout/"two platforms agree, the outlier is wrong" heuristics do not apply to — for obsolete members, the narrower footprint wins.
Widening an Enumerated #if Gate — Check Whether It Should Be an Exclusion Instead
When you need to add a backend to a gate already written as #if A || B || C, don't reflexively append || D — that grows an enumeration every new backend has to be found and added to by hand, one PR at a time. First check whether #if !X (exclude the backend that genuinely lacks the capability) is equivalent for every current consumer and would auto-include future backends for free. Verify by finding every csproj that compiles the guarded file/type and confirming none define a symbol outside {A, B, C, X} — grep each <Compile Include> site's DefineConstants, don't assume the known-symbol list is exhaustive from memory. Prefer the exclusion form whenever it's equivalent.
Lessons From Past Breakage
- ColoredRectangleRuntime unification first pass promoted MG+Raylib from
GraphicalUiElementtoInteractiveGueto match Skia. This would have made every decorative colored rectangle in every consumer project start absorbing clicks. Caught and reverted before merge. The correct resolution was the opposite direction — correct Skia down toGraphicalUiElement, since nothing in a decorative rectangle should eat events. - Takeaway: when two platforms agree and one doesn't, the outlier is more often wrong than the other two. Default to the majority behavior unless there's a platform-specific reason.
Incremental Unification Rule
Do not attempt to unify more than two platforms (one pair) in a single turn.
If a runtime class exists on MonoGame, Raylib, Skia, and Sokol:
- Pick a pair (e.g., MonoGame and Raylib).
- Research, propose, and unify only those two.
- Validate (build and run tests for that pair).
- Only after the first pair is stable and verified, proceed to the next platform (e.g., adding Skia to the existing unified file).
This ensures changes remain reviewable, TDD stays manageable, and build errors (like those often found in Sokol) don't block the entire unification process.
Incremental Convergence: Mirror-#if Toward an Empty Diff
The end state above (one source + #if + csproj link) is reached incrementally, by driving two still-separate per-platform copies of the same file toward byte-for-byte identical content — one corresponding block at a time. You do not have to convert a whole file, or even a whole method, in one pass. Pick any region that already lines up between the copies — a fragment inside a method is fine — and make just those lines identical. Inside-out and bottom-up are fine; top-down is not required.
For each difference inside the region you're converging, apply the same two-bucket classification (historical drift vs. platform-necessary), but at the line/block level:
- Historical drift → delete the divergence; make the lines literally identical with no guard. (Example for #3039: the
if (GraphicalUiElement.IsAllLayoutSuspended) { graphicalUiElement.IsFontDirty = true; return; }deferral guard is shared layout bookkeeping that Raylib simply never got — it should become identical, un-#if'd, in both copies.) - Platform-necessary → wrap the same lines in mirrored
#if RAYLIB/#if !RAYLIBin both copies, at the same spot, even though only one side's branch is live in each build. (Example: the font-loader body —BitmapFontvsRaylib_cs.Font— stays#if-gated.)
Either way, the cross-file diff for those lines goes to zero while each platform keeps compiling its own behavior. The metric is literally git diff --no-index <copyA> <copyB> shrinking every PR. When it reaches empty, the two files are one file: delete one, add a <Compile Include="…" Link="…"> to the orphaned csproj, done.
Gotcha that confuses fresh readers: a converged "home" file can carry #if RAYLIB branches even though the file is compiled into MonoGameGum, where RAYLIB is not defined. Gum/Wireframe/CustomSetPropertyOnRenderable.cs is the example: it's the single shared source for both platforms (linked into RaylibGum.csproj via <Compile Include>), so its #if RAYLIB … namespace RaylibGum.Renderables; branch is live in the Raylib build and simply dead code in the MonoGame build compiling the same file. Seeing #if RAYLIB inside a MonoGame-compiled file is not a bug.
Gotcha — the #nullable context travels with the consuming project, not the file. A
file-linked shared source compiles under each consumer's <Nullable> setting, and host
projects disagree (e.g. SkiaGum.Wpf has no <Nullable>, so a linked file's string?
annotations raise CS8632 there). Put #nullable enable at the top of the shared file so its
annotations stay valid and warning-free in every consumer regardless of their setting. (Issue
#3218, relocating the render-only GumService into WPF/MAUI/Silk hosts with differing settings.)
Gotcha — never add an extension method to a shared file that a project both links and references. RaylibGum.csproj <Compile Include>s files under RenderingLibrary/ and references GumCommon, which compiles the same sources, so every type in them exists twice in that build. Duplicate types are only a warning (CS0436 — the compiler prefers the source copy); duplicate extension methods are a hard CS0121 ambiguity at every call site, and it surfaces in the linking project only, so a green build of the file's home project proves nothing. Use an instance member or an explicitly-qualified static call instead.
Mechanical Steps
- Read all three per-platform source files end to end. Write down every difference.
- Classify each difference as platform-necessary or historical inconsistency. Ask the user about anything you can't classify with certainty — and always ask about base class and
HasEvents. - Write the unified file in
MonoGameGum/GueDeriving/, using the TextRuntime / ContainerRuntime idioms for:XNALIKEsymbol at the top (#if MONOGAME || FNA || KNI).using Color = ...;andusing ContainedXType = ...;aliases per platform.- Namespace switch:
Gum.GueDeriving(Raylib),SkiaGum.GueDeriving(Skia),MonoGameGum.GueDeriving(default).
- Add
<Compile Include="..\..\MonoGameGum\GueDeriving\FooRuntime.cs" Link="GueDeriving\FooRuntime.cs" />toRuntimes/RaylibGum/RaylibGum.csprojandRuntimes/SkiaGum/SkiaGum.csproj. - Delete the old per-platform files.
- Build
AllLibraries.sln— not individual csprojs. Plugin post-build scripts reference$(SolutionDir). - Run any
FooRuntime-filtered tests.
What This Skill Is Not
Not a general refactoring guide. Not a pattern for unifying non-runtime files. Specifically: the runtime unification pattern (shared source + #if + csproj linking) is appropriate because the Raylib and Skia runtime projects are small wrappers around the MonoGame-style API. Do not apply this pattern to tool code, to GumCommon code (which already lives in one place and is shared differently), or to Forms controls (which are linked as a directory glob).
Also not the pattern for duplication between two different runtime classes on the same platform (e.g. CircleRuntime and RectangleRuntime sharing near-identical XNALIKE-only Gradient/Dropshadow logic) — that's a composition problem, not a cross-platform file-link problem. See refactoring-direction's worked example (PR #3427) for that shape instead.
The convergence technique above, however, applies to any source that already exists as per-platform duplicate copies heading for a single linked home — not only GueDeriving wrappers. The canonical non-wrapper example, now fully converged, is the string-path dispatch/bridge file Gum/Wireframe/CustomSetPropertyOnRenderable.cs, linked into RaylibGum.csproj in place of a separate Raylib copy. The "don't apply this to tool code" exclusion is about not inventing the source-sharing pattern for things that are genuinely single-home; it does not forbid converging files that are already duplicated per platform.
Two CustomSetPropertyOnRenderable dispatchers, split by shape capability
The dispatcher exists as two separate source files — the split is by shape-renderable capability, not platform:
| File | Compiled into | Shape renderables it dispatches |
|---|---|---|
Gum/Wireframe/CustomSetPropertyOnRenderable.cs |
MonoGame · KNI · FNA · Raylib · tool | core geometry — LineRectangle/SolidRectangle/LineCircle/LinePolygon |
Runtimes/SkiaGum/CustomSetPropertyOnRenderable.cs |
SkiaGum · MonoGameGumShapes+KniGumShapes (Apos.Shapes) · SilkNet |
rich RenderableShapeBase family — Arc/RoundedRectangle/etc. |
The Skia file is #if SKIA/#else-split so one source serves both the Skia dispatcher and the Apos.Shapes dispatcher — the rich shape path is already shared between Skia and MonoGame-with-Apos. Both files are delegate-wired through GraphicalUiElement.SetPropertyOnRenderable (each backend's SystemManagers assigns it), so callers never name the class. Landmine: AposShapeRuntime and external FRB Glue reference these classes by namespace directly, so the namespace is not free to move. Convergence direction (runtime-type-first dispatch) and why the two files stay separate: Direction/decisions/0007-converge-skia-property-dispatch.md; the phased order to get there: 0008-sequence-runtime-dispatch-convergence.md. For why runtime-type dispatch is the right target (Runtimes are the layer that's actually portable across backends; renderables mostly aren't), see gum-architecture-layers.
This skill is also not the source of truth for which runtimes are unified. Roadmap and per-runtime status live in the (gitignored) design docs at .claude/designs/runtime-unification/ — RuntimeNorthStar.md for the workstream-level roadmap, RuntimeUnificationAndRefactor.md for per-runtime details. Update those when a unification lands; do not duplicate status here.
Version History
- c93866f Current 2026-08-20 09:20


