Plugin Authoring — Add a Domain with Zero Core Edits
A domain (skills, items, quests, …) joins SheetForge as a separate package that references SheetForge.Core — Core never references it back.
A plugin can add enums, custom cell types, wrapper types, domain validators, graph edges, structural markers, "Create sheet" templates, whole import sources, the Data Studio's canvas overrides, code registries, declarative authoring surfaces, widgets and actions, colour presets, custom cell widgets, pipeline observers, and its own localized UI strings — the sixteen contracts below.
"Adding a domain = zero Core lines changed" is compiler-enforced. A no-InternalsVisibleTo test assembly (SheetForge.Tests.Consumer) implements fifteen of the sixteen — and the capability interfaces beside them — using the public surface alone. If any of them were narrowed to internal the build would fail (CS0122). The sixteenth, the editor-only rich-panel escape hatch, returns a VisualElement and so is exercised by an editor-side test instead.
The eleven Core contracts are pure C#. That is what lets one compiled plugin DLL light up the same slots in the Unity editor and in the browser (SheetForge Web) — assembly and isolation are one shared Core function, and only discovery differs per host (Unity's TypeCache, the browser's uploaded-assembly scan).
The five Editor contracts hand back UIToolkit elements or touch window state, so they exist in the editor only.
All sixteen are discovered automatically — a parameterless constructor is the whole requirement, with no assembly reference, registration call or manifest to edit:
| Contract | Registers | Opt-in? |
|---|---|---|
ISheetForgePlugin | Enums + custom cell-type parsers | The base contract |
ISheetForgeValidatorPlugin | Domain validation rules (cross-column / cross-tab) | Opt-in add-on |
ISheetForgeEdgePlugin | Graph-edge declarations the core scanner can't see | Opt-in add-on |
ISheetForgeMarkerPlugin | Custom structural markers (per-column @marker rows) | Opt-in add-on |
ISheetForgeTemplatePlugin | "Create sheet" templates (tabs + example data) | Opt-in add-on |
ISheetForgeGraphPlugin | Per-tab canvas overrides for the Data Studio | Opt-in add-on |
ISheetForgeCodeRegistryPlugin | Read-only key spaces that live in code, as locked virtual tabs | Opt-in add-on |
ISheetForgeThemePlugin | Colour presets for the SheetForge windows (dark and light) | Opt-in add-on |
ISheetForgeStudioPlugin | Declarative authoring surfaces — actions, panels, column badges, cell-editor hints | Opt-in add-on |
ISheetForgeStringsPlugin | Your pack's UI strings, per language (an overlay consulted before the product tables) | Opt-in add-on |
ISheetForgePipelinePlugin | Pipeline observers — read-only notification of what an import produced | Opt-in add-on |
ISheetSourceProvider | A whole import source (DB / REST / in-house) | Independent (Editor assembly) |
IStudioGraphWidget | A domain widget above the Data Studio canvas | Independent (Editor assembly) |
IStudioInspectorAction | An extra button on the Data Studio node inspector | Independent (Editor assembly) |
IStudioCellEditorProvider | A custom input widget for one cell type in the Data Studio grid | Independent (Editor assembly) |
IStudioPanelProvider | An arbitrary UIToolkit panel in the Studio — the escape hatch beside the declarative one | Independent (Editor assembly) |
The reference sample is a selective import. The full worked example (
SheetForge.PluginDemo) ships as a Unity package atAssets/SheetForge/Examples/SheetForgePluginDemo.unitypackage— double-click it, or press Import Plugin Demo in the Getting Started window (Tools ▸ SheetForge ▸ Getting Started, the single place demo imports live), to restore it underAssets/SheetForge.PluginDemo/….Until you import it, it is not in your project at all — the sample ships only as that package — so its assemblies/types/tabs/addresses never collide with yours. The paths referenced below (
Assets/SheetForge.PluginDemo/ModifierCellParser.cs, etc.) exist once you've imported the package.(A second, plugin-free sample —
SheetForge.CoreDemo— demonstrates the pipeline with core built-in types only.)
The add-ons extend the base interface without changing it — a plugin that doesn't need validation or edges is untouched by their existence.
Seven further interfaces are capabilities rather than contracts:
- They are not discovered on their own.
- They are implemented in addition by something already registered.
- The Core finds them by casting that registered object.
Six are cast from a registered edge contributor or canvas override — see §4.12 for the discovery rule and each one. The seventh, IReferencingCellType, is cast from a registered cell parser. It gives your own notation the same reference handling RecordId@Tab gets — see §4.4a. Ignoring any of them changes nothing.
1. Package setup
Create a folder with its own .asmdef referencing SheetForge.Core (plus SheetForge.Runtime if you need runtime lookup). That's all. The Editor's PluginRegistry discovers your ISheetForgePlugin implementation via TypeCache and calls your registration methods, and registration is your explicit code, not an assembly scan.
Keep the Core-contract implementations in that main assembly. An editor-side companion assembly (referencing SheetForge.Editor as well) is where the five IStudio* / ISheetSourceProvider implementations go — the browser loads only your main DLL, so a Core contract implemented in the editor companion would silently be missing there.
1.1 Declaring compatibility (optional, one line)
An assembly-level attribute states which generation of the plugin format your assembly was built against, and the lowest host it wants:
using SheetForge.Core.Plugins;
[assembly: SheetForgePluginCompat(
SheetForgePluginFormat.Current, // the generation constant of the SDK you compiled against
MinHostVersion = "0.1.0", // optional — omit for "any host"
PluginVersion = "1.0.0")] // optional, display only- Omitting it is fine. An assembly with no declaration is read as generation
SheetForgePluginFormat.Minimumwith no host requirement, so plugins written before the attribute existed load exactly as they did. - The unit of judgement is the assembly, and a rejected assembly loses all of its registrations. A per-type declaration would let an undeclared neighbour type through and leave you with "refused, but half of it registered".
- The DLL is the judge, not the catalog. The market registry advertises the same two values (
pluginFormat,minHost) so a listing can be filtered before download, but the gate reads the attribute out of the verified bytes — a listing can be wrong, the compiled declaration cannot. - Rejection is a
PluginIncompatiblediagnostic naming what the assembly declared and what this host reads, not a silent disappearance. This is a compatibility declaration, not a signature: integrity is the distribution channel's job (see Web Plugin Market). - The generation number moves only if the plugin format itself is replaced. Purely additive growth — a new contract, a new member on a registry — never moves it, because your existing plugin keeps running without a recompile.
2. The base plugin: enums + custom cell types
using SheetForge.Core.Model;
using SheetForge.Core.Plugins;
public sealed class SkillsPlugin : ISheetForgePlugin
{
public string Name => "Skills"; // for diagnostics / duplicate-conflict reports
public void RegisterEnums(EnumRegistry enums)
{
// Any Enum<ActionType> / Enum<EffectType> cell in a sheet now resolves,
// and codegen emits the real CLR enum type on the generated field.
enums.Register<ActionType>();
enums.Register<EffectType>();
}
public void RegisterCellParsers(CellParserRegistry parsers)
{
// A custom cell type joins parsing, validation, codegen, bake and
// round-trip by registration alone (open-closed — zero pipeline edits).
parsers.Register(new ModifierCellParser());
}
}A custom cell type end to end
Implement ICellValueParser (string → value). To complete strong-typed baking and the Export/Push round-trip, also implement ICustomCellType (CLR type + value → canonical string).
The sample's Modifier mini-grammar (stat:op:value, e.g. attack:add:10):
using System;
using SheetForge.Core.Model;
using SheetForge.Core.Unparse;
public sealed class ModifierCellParser : ICellValueParser, ICustomCellType
{
// The @type cell text: a column declares "Modifier" or "List<Modifier>".
public string TypeName => "Modifier";
// ICustomCellType: the CLR value type codegen emits ([Serializable] struct).
public Type ValueType => typeof(Modifier);
public bool TryParse(CellParseContext context, string text, out object value)
{
value = null;
string[] parts = text.Split(':');
if (parts.Length != 3)
{
// Failure = collect a structured error and return false. Never throw.
context.Errors.Add(new ImportError(
ImportErrorCode.CustomTypeParseFailed, context.Coordinate,
text, "'stat:op:value' form (e.g. attack:add:10)", null));
return false;
}
// ... parse the three parts (InvariantCulture; reject NaN/Infinity) ...
value = new Modifier(parts[0].Trim(), /*op*/ default, /*value*/ 0f);
return true;
}
// ICustomCellType: value → canonical cell string (the exact inverse of TryParse).
public bool TryRender(object value, out string text, out string reason)
{
reason = null;
if (!(value is Modifier m)) { text = null; reason = "Not a Modifier."; return false; }
// Use CanonicalValueRenderer.RenderFloat for floats — round-trip-safe on Mono.
text = m.stat + ":" + "add" + ":" + CanonicalValueRenderer.RenderFloat(m.value);
return true;
}
}(See Assets/SheetForge.PluginDemo/ModifierCellParser.cs for the complete, production version with op-token validation and nearest-match suggestions.)
@target on custom types works by registration alone: declare a column as Modifier@Stats and your parser reads context.Type.TargetName ("Stats").
Integrity checking of that target — does the tab exist? does the id resolve? — belongs to a domain validator. That is the same division of labor as RecordId@Tab. An unregistered type name with @ is still an error with a suggestion, so typo safety is preserved.
Type names the Core already owns. The built-in scalar names — int, float, bool, string, Enum, RecordId, IntId, AssetRef, Color, AnimationCurve and Gradient — are registered before any plugin. A parser that reuses one of them fails registration with PluginRegistrationConflict — the built-in stays, that RegisterCellParsers call stops at the conflicting parser, and the plugin's other slots still load — so a pack that shipped its own Color or Gradient type must rename it (see the upgrade notes in the changelog). If your type stores a colour, a curve or a gradient, you do not have to re-implement the notation: the Core value models ColorValue, CurveValue and GradientValue expose TryParse(text, out value, out error) and Render(), CurveEvaluator / GradientEvaluator sample them exactly as Unity does, and a StudioCellEditorHint with the ColorPicker, CurveEditor or GradientEditor archetype (§4.16) opens the native editor for your type in both hosts.
A wrapper type end to end (MyWrapper<T>)
A wrapper is a generic value shape — Pair<int> = 1~2 — that packs several inner T values into one cell. You own only the outer syntax (delimiter, arity), and the Core parses the inner T recursively. So Pair<RecordId@Effects>, Pair<Enum<DamageType>>, and nested Box<Pair<int>> parse and validate with no additional code, and references inside are fully validated.
Implement ICellWrapperType and register it in the same RegisterCellParsers hook via parsers.RegisterWrapper(...):
// A [Serializable] generic value type — codegen emits Pair<int>, Pair<RecordRef>, ...
[Serializable] public struct Pair<T> { public T First; public T Second; public Pair(T a, T b){First=a;Second=b;} }
public sealed class PairWrapper : ICellWrapperType
{
public string Name => "Pair"; // the @type token: Pair<Inner>
public Type OpenClrType => typeof(Pair<>); // generic open type — exactly one type parameter
// Outer syntax only: split "1~2" into ["1","2"]. Use a delimiter OTHER than ';'
// so List<Pair<T>> doesn't clash with the list separator.
public bool TrySplit(string cell, out IReadOnlyList<string> pieces, out string reason)
{
reason = null;
var parts = (cell ?? "").Split('~');
if (parts.Length != 2) { pieces = null; reason = "'a~b' form (two parts)."; return false; }
pieces = new[] { parts[0], parts[1] };
return true; // the Core parses each piece as the inner type
}
public string JoinCanonical(IReadOnlyList<string> inner) => inner[0] + "~" + inner[1]; // inverse of TrySplit
public object Assemble(IReadOnlyList<object> inner, Type closed) =>
Activator.CreateInstance(closed, inner[0], inner[1]); // bake: build Pair<TInner>
public bool TryDisassemble(object v, out IReadOnlyList<object> inner, out string reason)
{
reason = null;
var t = v.GetType();
inner = new[] { t.GetField("First").GetValue(v), t.GetField("Second").GetValue(v) };
return true; // Export: read the values back out (inverse of Assemble)
}
}
// In your ISheetForgePlugin.RegisterCellParsers:
public void RegisterCellParsers(CellParserRegistry parsers) => parsers.RegisterWrapper(new PairWrapper());That single registration gets you:
- Recursive
@typeresolution. - Strong-typed codegen (
Pair<RecordRef> First;). - Baking.
- The Export/Push round-trip.
- Reference pass-through — a
RecordId@Tabinside the wrapper is integrity-checked, propagated on key rename, and rewritten on tab rename.
Rejection rules and the ;-delimiter caveat are documented in Sheet Syntax.
3. Domain validators (opt-in)
Core validation is fixed at four kinds (keys, references, @overlap, asset keys). For cross-column rules ("if type is Custom, script is required") or cross-tab rules (checking the meaning of a referenced record), implement ISheetForgeValidatorPlugin:
using SheetForge.Core.Model;
using SheetForge.Core.Plugins;
using SheetForge.Core.Validation;
public sealed class SkillsPlugin : ISheetForgePlugin, ISheetForgeValidatorPlugin
{
// ... Name / RegisterEnums / RegisterCellParsers unchanged ...
public void RegisterValidators(DomainValidatorRegistry validators)
{
validators.Register(new CustomEffectRequiresScriptValidator());
}
}
public sealed class CustomEffectRequiresScriptValidator : IDomainValidator
{
public string Name => "CustomEffectRequiresScript";
public void Validate(DomainValidationContext ctx)
{
if (!ctx.Tables.TryGetValue("ExampleEffects", out var effects)) return;
if (!effects.Schema.TryGetField("script", out var scriptField)) return;
foreach (var rec in effects.Records)
{
if (!(rec["type"].Value is EnumValue ev) || ev.MemberName != "Custom") continue;
var scripts = rec["script"].AsList;
if (scripts != null && scripts.Count == 0)
ctx.Errors.Add(new ImportError(ImportErrorCode.DomainRuleViolation,
new CellCoordinate("ExampleEffects", rec.RowNumber, scriptField.ColumnNumber, "script"),
/* what */ rec["codeName"].Value.ToString(),
/* why */ "A Custom effect must specify a script to run, but 'script' is empty.",
/* how */ "Put a script address in the 'script' column, or change 'type'."));
}
}
}Registered validators automatically join both import validation and the authoring pre-flight. Rules:
- Report violations into
ctx.ErrorsasImportErrorCode.DomainRuleViolation— never throw. A thrown exception is isolated and promoted; the other validators still run. - Fill all four elements — where (
CellCoordinate), what (ActualValue), why (Expected), how (Suggestion). The "how" is displayed verbatim as the actionable sentence. ctxgives you:- all parsed tables (
Tables), - key indices (
KeyIndices), - asset keys (
AssetKeys—nullmeans asset validation was skipped).
- all parsed tables (
- Collect-everything and no-partial-assembly are inherited automatically.
4. Edge contributors (opt-in)
If you build tooling over the data graph (or want a future graph canvas to see your domain's connections), declare edges the core reference scanner cannot see — e.g. a stat referenced inside a mini-grammar value:
public sealed class SkillsPlugin : /* ... */, ISheetForgeEdgePlugin
{
public void RegisterEdgeContributors(EdgeContributorRegistry contributors)
{
contributors.Register(new ModifierStatEdgeContributor()); // effect → stat edges
}
}An IEdgeContributor receives a read-only cross-tab context and appends EdgeSpec items (from/to tab + record id, optional field, payload record, label). Contributors never emit diagnostics — edges are projection material, not validation. See Authoring Kernel.
4.4 Recipe: a custom type that holds a key inside it
RecordId@Tab is the one reference shape the Core understands, and it gets integrity checking, graph edges, nearest-match suggestions and rename propagation for free.
The moment your own notation swallows a key — attack:add:10, stat.hp>50, fire@0.4 — the Core sees one opaque string, so those four services stop at your doorstep. Three registrations put three of them back. Write them as a set: a mini-syntax with one of the three is the shape that produces "it imports fine but nothing points at anything".
| Piece | Contract | What it restores | Without it |
|---|---|---|---|
| 1. Integrity | IDomainValidator (§3) | A key inside your notation that does not exist is reported, with the coordinate and an actionable sentence | A typo imports cleanly and fails at runtime |
| 2. Visibility | IEdgeContributor (§4) | The buried link becomes a real edge: the canvas draws it, the Used by list counts it, the reference index indexes it | The connection exists in the data and nowhere on screen |
| 3. The "how" | TextSuggestion.FindNearest inside piece 1 | "Unknown stat 'atack'. Did you mean 'attack'?" — the same sentence shape the built-in reference errors use | A correct diagnosis with no way to act on it |
// Piece 1 + 3 together — the validator is where the suggestion belongs, because it is the
// only one of the three that produces a sentence a person reads.
using SheetForge.Core.Model;
using SheetForge.Core.Validation;
public sealed class ModifierStatExistsValidator : IDomainValidator
{
public string Name => "ModifierStatExists";
public void Validate(DomainValidationContext ctx)
{
if (!ctx.KeyIndices.TryGetValue("Stats", out var stats)) return; // no target tab: nothing to check
if (!ctx.Tables.TryGetValue("Effects", out var effects)) return;
if (!effects.Schema.TryGetField("modifier", out var field)) return;
foreach (var rec in effects.Records)
foreach (string statKey in StatKeysIn(rec["modifier"])) // your notation's own split
{
if (stats.Contains(statKey)) continue;
string near = TextSuggestion.FindNearest(statKey, stats.Keys); // piece 3
ctx.Errors.Add(new ImportError(ImportErrorCode.DomainRuleViolation,
new CellCoordinate("Effects", rec.RowNumber, field.ColumnNumber, "modifier"),
/* what */ statKey,
/* why */ "This modifier points at a stat that does not exist in 'Stats'.",
/* how */ near != null
? "Did you mean '" + near + "'? Fix the stat name in the modifier value."
: "Add that record to 'Stats', or correct the stat name."));
}
}
}Reuse one splitter for the notation. The parser, the validator and the edge contributor must agree on where a key starts and ends, and three private copies of that split is how they drift apart. (A wrapper type, §2, gets this for free: TrySplit is the shared splitter.)
The fourth service — rename propagation — needs one more thing, and there are two ways to get it. Renaming a record rewrites referring cells only where the Core can find the key in the text. It can do that for a RecordId@Tab field, a list of them, and a wrapper whose TrySplit exposes the key as an element. It cannot guess at your grammar's substring boundaries on its own. So you either:
- Tell it how — implement
IReferencingCellType(§4.4a), which replaces this whole three-piece recipe with one opt-in and restores all four services at once. - Accept the boundary, which is at least honest rather than silent: piece 1 reports the now-dangling key on the next import, with the coordinate and the suggestion.
The recipe above is still the right answer in one case: when the column has no @target because there is no single tab the key lives in. The bundled sample is exactly that. List<Modifier> names no target, so the Core cannot know where attack should resolve, and ModifierStatEdgeContributor opens those edges by hand. Give the column a target (List<Modifier@Stats>) and §4.4a takes over.
4.4a Giving your own notation full reference parity (opt-in)
Implement IReferencingCellType on a parser you already register, and a MyType@Tab column stops being a special case: it is validated, suggested, propagated, drawn, picked and indexed exactly like RecordId@Tab.
There is no new registration channel. The Core casts the parsers already in CellParserRegistry, the same way canvas capabilities are cast from registered edge contributors (§4.12). A custom type that does not implement it behaves exactly as it did before, bit for bit.
The five hooks
They all work on one element: the whole cell for a scalar column, or one ;-separated element for List<MyType@Tab> — the same unit your ICellValueParser.TryParse receives.
| Hook | Answers | Used for |
|---|---|---|
bool TryGetTokenKey(elementText, out key) | "What does this element point at?" | Membership — is this cell already linked to that record |
string MakeToken(key) | "Write a new link to this key" | An empty cell, or appending to a list. Fill the payload with a neutral starting point; an authoring surface must not invent values. Return null/empty and the gesture is disabled with a reason instead of faked |
bool TryRetargetToken(elementText, newKey, out newText) | "Point this at something else" | Picking a different record in the ▾ cell, and re-aiming a wire on the canvas. Change the target only — removing and re-making the token would reset the numbers a person typed |
bool TryRemoveToken(elementText, key, out newText) | "Unlink this" | Return empty text and the element disappears (the scalar cell clears, the list element is dropped); return non-empty and that much stays |
bool TryRewriteKeys(elementText, renames, out newText) | "Substitute all of these keys" | The rename sweep. Separate from TryRetargetToken because that one is a single instruction from a person while this one is a bulk pass — and an element holding two references must rewrite both |
The text half and the value half
Add IRefBearingValue to the parsed value as well — the two halves do different jobs and both are needed. The text half cannot see a parsed value; the value half cannot restore the notation the author typed:
using System.Collections.Generic;
using SheetForge.Core.Model;
// Text half — on the parser. `stat:op:value`, e.g. attack:add:10
public sealed class ModifierCellParser : ICellValueParser, ICustomCellType, IReferencingCellType
{
public bool TryGetTokenKey(string t, out string key)
{
key = Head(t); // the first segment is the reference
return key.Length != 0;
}
public string MakeToken(string key) => key + ":add:0"; // neutral, ready to edit
public bool TryRetargetToken(string t, string newKey, out string newText)
{
newText = newKey + Rest(t); // the residue is preserved
return Head(t).Length != 0;
}
public bool TryRemoveToken(string t, string key, out string newText)
{
newText = string.Empty; // nothing is left without the key
return Head(t) == key; // not ours → false, never overwrite blindly
}
public bool TryRewriteKeys(string t, IReadOnlyDictionary<string, string> renames, out string newText)
{
newText = t;
if (!renames.TryGetValue(Head(t), out string to)) return false;
newText = to + Rest(t); // attack:add:10 → power:add:10
return true;
}
// … TypeName / TryParse / ValueType / TryRender as in §2
}
// Value half — on the value the parser produces.
public struct Modifier : IRefBearingValue
{
public string stat; public string op; public float value;
IEnumerable<string> IRefBearingValue.ReferencedKeys =>
string.IsNullOrEmpty(stat) ? System.Array.Empty<string>() : new[] { stat };
}Implementing an interface adds no fields, so the baked ScriptableObject and the generated code are unchanged.
What you get, from one opt-in — every one of these is the Core's own code path, not a re-implementation:
- Integrity + suggestions — a key that does not exist is reported as
UnresolvedRecordIdwith the coordinate and "did you mean …", sharing the per-field suggestion budget with built-in references. - Rename propagation with the payload intact — renaming
attacktopowerrewritesattack:add:10intopower:add:10; the operator and the number are the author's, and they survive. - Graph — the link becomes a real edge with coordinates: it is drawn, the node gets a port, the Used by list counts it, and the reference index has it both ways.
- The
▾picker — the cell gets the same searchable dropdown aRecordId@Tabcell has, and picking a different record replaces the target and keeps the residue. Without the registry the picker declines rather than pasting a bare key over your value. - Orphan detection and the exported dropdown rule — a row whose only outbound link lives inside your notation is no longer treated as unconnected. A scalar column of your type also gets a data-validation dropdown over the target tab's keys (Sources, Export & Push).
The simplest use is an alias type. Say the value is just a key and the cell text is that key:
TryGetTokenKeytrims.MakeTokenreturns the key.TryRetargetTokenreturns the new key.TryRemoveTokenreturns empty.
The column is then a RecordId@Tab in every functional respect. The only thing left to you is presentation: it appears under its own name in @type, and you can attach a cell widget (§4.13) or a canvas shape (§4.7) to that column alone. No separate contract is needed for an alias.
Two constraints, both structural:
- No
;in the payload. The Core splits a list cell into elements before your parser or any of these hooks sees the text, so a semicolon inside a value would be shredded into two elements. (Wrapper types carry the same constraint for the same reason.) @targetmust name a real sheet tab, exactly asRecordId@Tabdoes — a code registry's virtual tab is rejected withUnknownTargetTab. That restriction is what lets unresolved-reference reporting, nearest-match suggestions and rename propagation be the Core's own, unmodified.
None of the five hooks may throw: answer false or null for anything you cannot interpret, and preserve the residue whenever you rewrite.
It works against an integer key space too. If the tab your @target names keys on IntId rather than RecordId, nothing changes in your code — the key your hooks hand back and receive is simply the integer written as text. Which key space to compare against is decided by the target tab's own identity, not by your type.
- Validation, nearest-match suggestions, rename propagation, edges, the picker and orphan detection all light up the same way.
- One nicety the Core adds for you there: because an integer can be spelled several ways, a rename hands
TryRewriteKeysthe spelling as it appears in that element alongside the canonical one (007and7both map to12), so an ordinal lookup inside your type does not miss a padded value. - The bundled demo does not include a referencing custom type aimed at an
IntIdtab — itsModifierexample targets a string-keyed one — so this path has tests but no worked sample to copy.
4.5 Custom structural markers (opt-in)
The built-in markers are @name, @type, @desc, and three optional ones:
@overlap.@style, which describes the sheet — its group label and colour — rather than its columns.@enum, which marks the sheet as a set of enum definitions rather than a table.
@overlap is a per-column marker: its row carries one value per column, validated column by column. You can register your own markers the same way — for example a @curve marker that records how each numeric column interpolates. Implement IStructuralMarkerDefinition and register it via ISheetForgeMarkerPlugin:
// A hypothetical plugin (the bundled Plugin Demo does not register a marker):
public sealed class CurvesPlugin : /* ... */, ISheetForgeMarkerPlugin
{
public void RegisterStructuralMarkers(MarkerRegistry markers)
{
markers.Register(new CurveMarker());
}
}
public sealed class CurveMarker : IStructuralMarkerDefinition
{
public string MarkerName => "curve"; // without '@' → the sheet row is @curve
public string Description => "How this column interpolates (linear/ease/step).";
// Validate this column's @curve cell. Empty is allowed (defaults to linear).
public void ValidateCell(MarkerCellContext context)
{
string v = context.RawText.Trim();
if (v.Length == 0) return; // you decide what an empty cell means
if (v != "linear" && v != "ease" && v != "step")
context.Reject("@curve must be linear, ease, or step", "use one of: linear, ease, step");
}
}The sheet then accepts a @curve row (any order, above the data):
@name | level | atk
@type | int | int
@curve | | ease
| 1 | 10- The value is stored as domain-agnostic metadata:
field.MarkerValues["curve"]. A domain validator or edge contributor reads it fromcontext.Tables[tab].Schema.Fields[i].MarkerValues; the authoring window shows it in the column header tooltip. - A rejected cell becomes a
MarkerCellInvaliddiagnostic — you supply the "why" and "how to fix"; the Core supplies the coordinate and the offending value. - Marker names must be valid identifiers and must not collide with the built-in six (
@name/@type/@desc/@overlap/@style/@enum—Registerthrows otherwise, surfaced as aPluginRegistrationConflict). - Markers are for per-column metadata, not new data shapes — a marker owns its cell validation, not the whole row. Custom marker rows are preserved verbatim on export/round-trip and moved with their column by every structure edit (add / delete / move / rename).
- Codegen does not bake marker values (like
@overlap, they are validation/display metadata only, invisible to the schema fingerprint).
4.6 "Create sheet" templates (opt-in)
The Create sheet flow ships two built-in templates — an items sheet using core types only, and an @enum definitions sheet — plus "from scratch".
Domain templates are sheet skeletons that use your enums, custom types, and references. They come from plugins, so a template is present exactly when its plugin is. Implement ISheetForgeTemplatePlugin:
public sealed class SkillsPlugin : /* ... */, ISheetForgeTemplatePlugin
{
public void RegisterTemplates(TemplateRegistry templates)
{
templates.Register(new DataTemplate(
"skills.demo", // registry key (unique; duplicates rejected)
"Skill demo (Actions · Effects · Skills)", // your own display string
new List<DataTemplateTab>
{
// Each tab carries a full TSV: marker rows + example data.
new DataTemplateTab("Actions", "@name\tcodeName\ttype\n@type\tRecordId\tEnum<ActionType>\n\tfireball\tProjectile"),
new DataTemplateTab("Effects", /* ... */ ""),
new DataTemplateTab("Skills", /* ... */ ""),
}));
}
}- A template carries one or more tabs, each a complete normalized TSV: comment/marker rows plus example data. That is unlike the built-in item example, which is a 0-row skeleton. Because your domain types are already registered (the plugin is loaded), the created sheets re-import successfully right away.
- Display strings are yours. A plugin owns its own text (the example package is outside the domain-word guard) — you are not restricted to Core
Lockeys. - Multi-tab templates create all their tabs and re-import once, so cross-tab references resolve together. The Create panel hides the tab-name field for these (tab names are fixed by the template).
- Keys, empty display names, zero tabs, and empty tab TSV are rejected (
Registerthrows, surfaced as aPluginRegistrationConflict).
4.7 Per-tab canvas overrides (opt-in)
The Data Studio canvas decides what to draw on its own. You open a record — the terminus — and it walks the reference index outward, collecting everything that record consumes, then lays the result out left to right. That works with no plugin at all.
What a plugin adds is what the core cannot see or cannot know:
- an identity that is not a sheet record,
- a link that is not written in a
RecordId@Tabcolumn, - an order that is domain rule rather than reference depth.
Implement IRecordCanvasAugmenter and register it per tab via ISheetForgeGraphPlugin:
using SheetForge.Core.Graphing;
using SheetForge.Core.Plugins;
public sealed class SkillsPlugin : /* ... */, ISheetForgeGraphPlugin
{
public void RegisterGraphShapes(GraphShapeRegistry shapes)
{
shapes.Register("ExampleActions", new ExampleReactiveAugmenter()); // tab name → override
}
}
public sealed class ExampleReactiveAugmenter : IRecordCanvasAugmenter
{
public void Augment(GraphBuildContext context, CanvasAugmentBuilder builder,
string terminusTab, string terminusRecordId)
{
// context = Tables (parsed sheets) · References (indexed both ways) · CodeRegistries
// ① A virtual node: an identity that is not a sheet record. The tab may be empty —
// then the key alone identifies it. The last argument is where clicking it jumps.
builder.AddNode(string.Empty, "evt:impact_landed", "impact_landed", "event");
// ② An extra edge the core scanner cannot see (this link lives in a plain string column).
// Naming the field says *which cell* it is written in; leaving it out keeps the wire
// display-only. Direction is "A uses B", and B is drawn to the left of A.
builder.AddEdge(terminusTab, terminusRecordId, string.Empty, "evt:impact_landed",
/*label*/ "listen", /*fieldName*/ "listen");
// ②b An edge drawn one way whose cell lives on the other end, and a loop you know about.
// Both are trailing arguments — the short call above still compiles unchanged.
builder.AddEdge(string.Empty, "evt:impact_landed", terminusTab, terminusRecordId,
label: "raises", fieldName: "raises", fieldOnTarget: true,
isCyclic: true, cyclicNote: "brake 0s — no damping");
// ③ A layer hint. Absolute columns count from 0 at the left (negative goes further left);
// relative columns count from the terminus, which is what a fixed stage usually means.
builder.SetLayerRelative(string.Empty, "evt:impact_landed", -2);
// ④ A display hint: what a human calls this record. Only you know which column is a name.
builder.SetSubtitle(terminusTab, terminusRecordId, "Counter strike");
}
}- Registration is per tab name. Tabs you don't register still get a canvas — the core closure — so a plugin never has to cover every sheet. A duplicate tab, an empty tab name and a null override are rejected (
Registerthrows, surfaced as aPluginRegistrationConflict). - You add, you do not replace. Which records appear is the closure's answer. A virtual node whose (tab, key) is already on screen is dropped — the real record wins — so an override cannot invent a record that exists in a sheet. What it can do is bring in identities that have no sheet row at all.
- Names are the one exception. A display hint is presentation rather than identity, so it does apply to records that already exist. It may also name records that are not on screen at all: the connect picker reads those, which is why a card's subtitle and a picker row say the same thing. Blank names are ignored (that is the same as "use the default"), and the first name for a record wins.
- An edge brings its own node. If one end of an extra edge is not on screen, it is added as a node so the link never dangles. An edge with an empty key on either end is ignored.
- Where the cell is, and where the arrow points, may differ. By default the cell named by
fieldNameis assumed to sit on the departure record. PassfieldOnTarget: truewhen it sits on the arrival instead — a published-event wire is drawn event → record, but the text is in the record's own column. The wire inspector then points at the real cell rather than nothing. - Cycles: the core marks the ones it can see, you declare the ones you know. If your extra edges close a loop, the canvas classifies the back edge and draws it dashed on its own. Judging whether a cycle is a problem is a domain validator's job (§3); the canvas is display material, never validation.
isCyclicmarks a wire as a cycle for display without touching the layout.cyclicNotecarries what only you know (a damping value, say) — keep the label the column name and put the explanation in the note.
- Layer hints come in two flavours.
SetLayeris absolute — column 0 is leftmost and negatives go further left.SetLayerRelativecounts from the terminus (−1 is the column immediately left of it), which is usually what a fixed stage means. The picture then reads the same whether the chain is shallow or deep, and you do not have to pin the terminus itself to keep the stages from colliding.- Relative hints resolve against the terminus column before any hint moved it, so the order you add hints in cannot change the result. If the result goes left of zero the whole picture shifts right.
- A hint for a node that is not on screen is dropped, and the first hint for a node wins.
- Failures are contained.
Augmentruns inside try/catch: an exception becomes an English console warning and the core picture, never a broken window. - Widening never breaks you. Every capability added since the first release is a trailing argument or a new method; an override written against the earlier surface compiles and behaves identically.
(See Assets/SheetForge.PluginDemo/Graphing/ExampleReactiveAugmenter.cs and ExamplePipelineAugmenter.cs for the complete overrides — a reaction that grows event nodes and a code block around the record, and a cast whose fixed stages are pinned to their own columns.)
4.8 Code registries — reference targets that live in code (opt-in)
Some reference targets are not authored in a sheet at all: the execution atoms your runtime dispatches to. Registering them as a locked virtual tab puts them on the authoring surface read-only, and stops edges that point at them from being drawn as broken. Implement ISheetForgeCodeRegistryPlugin:
using System.Collections.Generic;
using SheetForge.Core.Graphing;
using SheetForge.Core.Plugins;
public sealed class SkillsPlugin : /* ... */, ISheetForgeCodeRegistryPlugin
{
public void RegisterCodeRegistries(CodeRegistryCatalog catalog)
{
catalog.Register(new CodeRegistrySource("_Refs", new List<CodeRegistryEntry>
{
// key = the referenceable id · label = shown text · raises = optional related keys
new CodeRegistryEntry("action.projectile", "Projectile launch", new[] { "impact_landed" }),
new CodeRegistryEntry("effect.script", "Script effect", null),
}));
}
}- Three consumption points:
- the Data Studio sidebar shows the virtual tab under READ-ONLY as a key/label/raises grid;
- a canvas override can look the entries up through
context.CodeRegistries; - and the node inspector lists an entry's
Raises.
- The keys join the Studio's existence check. An edge whose target is a registered key — typically one declared by an
IEdgeContributor(§4) or built by your shape — is not painted as a broken reference. - The import validator does not know virtual tabs. Code registries are an authoring-surface concept, so do not type a sheet column as
RecordId@_Refs(the import would reportUnknownTargetTab). Connect sheet data to code atoms the way the demo does — atypecolumn plus an edge contributor / shape lookup. - Pick a name that cannot collide with a real sheet (the demo prefixes with
_). If one does collide, the Studio badges the clash in the sidebar rather than silently hiding either. - Rejections:
nullsource, an empty tab name, or a duplicate tab name throws (surfaced asPluginRegistrationConflict); anullRaiseslist is normalised to empty. Core treats key / label / raises as opaque strings — it never interprets them.
(See Assets/SheetForge.PluginDemo/Graphing/ExampleCodeAtoms.cs.)
4.9 Data Studio graph widgets (opt-in, Editor assembly)
A widget is a strip of your own UI above the graph canvas — a fixed stage overview, an aggregate badge, whatever the domain wants. The core ships no widgets, so this area is empty until a plugin fills it.
Because the return type is a VisualElement, this contract lives in the Editor assembly — the same justified asymmetry as ISheetSourceProvider. Implement it in an Editor-side assembly that references SheetForge.Editor and SheetForge.Core:
using SheetForge.Editor.Studio;
using UnityEngine.UIElements;
public sealed class ExampleStageStripWidget : IStudioGraphWidget
{
// context = Tab · ShapeId · ModeId · FocusRecordId · FocusRecord · Tables · References · CodeRegistries
public bool AppliesTo(StudioGraphContext context) =>
context.Tab == "ExampleSkills" && context.FocusRecord != null;
public VisualElement Create(StudioGraphContext context)
{
var strip = new VisualElement();
strip.Add(new Label("VALIDATE → CAST → COMMIT → DELIVER → APPLY"));
return strip; // return null to add nothing
}
}- Discovery is automatic —
TypeCachefinds every implementation with a parameterless constructor; there is no registration call and no registry to bind. An instantiation failure is logged and skipped. - Read-only by contract. The context exposes the parsed tables, the reference index and the code registries — but no staging surface. Authoring from the graph belongs to an inspector action (§4.10), which mediates it.
- No state inside the element. Widgets are re-created on every graph rebuild; keep state in your own objects. Rebuilds are coalesced to human-action frequency, not keystrokes.
- Exceptions are isolated —
AppliesTo/Createthrowing produces an English console warning; the graph still draws.
(See Assets/SheetForge.PluginDemo/Demo/Editor/ExampleStageStripWidget.cs.)
4.10 Data Studio inspector actions (opt-in, Editor assembly)
An action is an extra button on the node inspector — "what this domain can do with this record". The core provides one built-in action (Go to this sheet); everything else arrives through this contract:
using SheetForge.Editor.Studio;
public sealed class ExampleInspectorAction : IStudioInspectorAction
{
// A Loc key. The demo registers this key's sentences per language (§4.14);
// an unregistered key is displayed verbatim, so plain text also works.
public string LabelKey => ExampleLocStrings.BrakeActionKey;
public bool AppliesTo(StudioInspectorContext context) =>
context.Tab == "ExampleActions" && context.Record != null;
public void Execute(StudioInspectorContext context)
{
// Mediated mutation: the window turns this into one Undo step + one staged edit
// carrying the logical address (tab · record id · field).
context.StageCell(context.Tab, context.RecordId, "brakeSeconds", "0.25");
// Show the user what changed: (tab, original sheet row number, field); row 0 = tab only.
context.FocusCell(context.Tab, context.Record.RowNumber, "brakeSeconds");
context.RequestRebuild();
}
}- The authoring session is deliberately not exposed. Every staging change must be one native-Undo step with the projection generation bumped; handing out the raw session would institutionalise a way around that rule.
StageCell(tab, recordId, field, rawText)andStageCells(writes)are the whole mutation surface, and the window owns the bookkeeping. - Changing several cells? Use
StageCells.context.StageCells(new[] { new EdgeCellWrite(tab, recordId, field, text), … })stages the whole list as one Undo step, all or nothing: if one write cannot be applied, none is. CallingStageCellseveral times splits Ctrl+Z into that many steps, and for parallel columns that means a half-valid state appears in the middle of undoing. A null or empty list does nothing. - Pass canonical text. The staged text is parsed by the same parser the importer uses, at reflect time — so write what the sheet would contain.
- A key that is not in the baseline is a no-op (a brand-new or unresolved record): nothing is written silently.
- Services:
FocusCellscrolls the grid to a coordinate,RequestRebuildasks for a refresh after you stage something. - Discovery, labels and isolation work exactly like widgets:
TypeCachediscovery, verbatim fallback for an unregisteredLabelKey(an empty key falls back to the type name), and try/catch aroundAppliesTo/Execute.
(See Assets/SheetForge.PluginDemo/Demo/Editor/ExampleInspectorAction.cs. Its Editor assembly — SheetForge.PluginDemo.Demo.Editor — references SheetForge.Editor, SheetForge.Core and the plugin assembly; that is all the wiring an Editor-side extension needs.)
4.11 Colour presets (opt-in)
SheetForge paints its own windows from a small vocabulary of colour slots (surfaces, lines, text, semantic colours, staging marks). A preset re-colours the slots it cares about; every other slot keeps the product default. Implement ISheetForgeThemePlugin:
public sealed class SkillsPlugin : /* ... */, ISheetForgeThemePlugin
{
public void RegisterThemes(ThemeRegistry themes)
{
themes.Register(new SheetForgeTheme(
"skills.forge", // registry key (unique; the built-in ids are reserved)
"Forge (Skill demo)", // your own display string
new Dictionary<ThemeColorSlot, uint> // dark screens
{
{ ThemeColorSlot.Accent, 0xff9a4d },
{ ThemeColorSlot.Canvas, 0x120d0a },
{ ThemeColorSlot.Text, 0xe8dccf },
},
new Dictionary<ThemeColorSlot, uint> // light screens
{
{ ThemeColorSlot.Accent, 0x9c4a10 },
{ ThemeColorSlot.Canvas, 0xf7f2ec },
{ ThemeColorSlot.Text, 0x2b1f16 },
}));
}
}- Colours are
0xRRGGBB. Core references no engine type, so there is noUnityEngine.Colorhere; the top byte is ignored. Translucent surfaces (badge fills, the modal scrim) are derived from a slot colour plus a fixed alpha — you set the colour, not the alpha. - Give both screens. Supply a dark map and a light map; the user's brightness choice (follow editor / always dark / always light) picks one. Slots you leave out fall back to the product default for that brightness, so a three-slot preset is perfectly normal.
- Registering does not apply it. Your preset appears in
Preferences ▸ SheetForge ▸ Theme ▸ Colour presetbeside the built-in Default and High contrast; only the user's pick takes effect. Display strings are yours (no CoreLockey needed). - Blank ids, duplicates, and the reserved built-in ids (
default,highContrast) are rejected (Registerthrows, surfaced as aPluginRegistrationConflict). - What a theme cannot restyle: the native Unity widgets drawn inside our windows (button chrome, field borders) keep following the editor skin — see Capabilities & Limits.
4.12 Editing on the graph canvas (opt-in)
The Data Studio's graph is an authoring surface, not a picture: right-clicking creates records, connects them and disconnects wires (see Data Studio). All of that works on a plain project for ordinary RecordId@Tab columns.
The capabilities below extend it where the core cannot reach. None of them changes an existing contract, so a plugin that ignores them compiles unchanged.
How capabilities are discovered (read this first)
A capability is never discovered on its own. The window finds every one of them by casting the objects that are already registered:
| Capability | Cast from | What it adds |
|---|---|---|
IAuthorableGraphShape | the canvas override registered by ISheetForgeGraphPlugin | Where new records may be created |
IAuthorableEdgeContributor | the edge contributor registered by ISheetForgeEdgePlugin | Turn one gesture into one cell write |
IBatchAuthorableEdgeContributor | the same edge contributor | Turn one gesture into several cell writes |
IVirtualNodeFactory | the same edge contributor | Offer "create one more" on the node menu |
IEdgeSlotDeclarer | the same edge contributor | Declare connect slots the schema cannot derive |
IEdgeTokenEditor | the same edge contributor | Describe a token and edit the part that is not the key |
So the five edge-side capabilities are only reached if the class is registered as an IEdgeContributor (via ISheetForgeEdgePlugin, §4). If your domain opens no edges of its own, that is not a reason to skip registration — implement ContributeEdges as an empty method and register it anyway. That empty contributor is the officially supported way to join:
public sealed class ExampleSlotPlugin : ISheetForgeEdgePlugin
{
public void RegisterEdgeContributors(EdgeContributorRegistry contributors)
=> contributors.Register(new ExampleSlotContributor());
}
public sealed class ExampleSlotContributor : IEdgeContributor, IEdgeSlotDeclarer
{
public string Name => "ExampleSlots";
// Nothing to declare — this class is here for the capabilities below.
public void ContributeEdges(EdgeContributionContext context, ICollection<EdgeSpec> edges) { }
public IReadOnlyList<DeclaredSlot> DeclareSlots(EdgeAuthoringContext context,
string nodeTab, string nodeRecordId) => …;
}All of them run inside try/catch: an exception becomes an English console warning and disables that one affordance, nothing else.
Where new records may be created — IAuthorableGraphShape
There are two defaults, and they are deliberately different.
- The creatable-tabs list — the axis this capability replaces, which also decides whether a canvas opens at all — covers every tab the focus tab's schema can reach, following references transitively. It is computed from the schema, not the data, so it holds even on a sheet that has no rows yet. Reaching a tab two links deep is stepwise: create the intermediate record, its ports appear, and the next hop joins the cascade.
- The linking cascade — the picker you actually see on empty canvas — is narrower. It starts from the tabs the ports currently drawn on screen aim at.
Either way, tabs owned by a code registry and tabs with no key column are dropped, because a new record there could not have an identity.
An override registered for that tab (§4.7) can add this interface to replace both defaults. A tab it names that no port on screen accepts stays listed in the linking cascade with its reason attached rather than disappearing:
using SheetForge.Core.Graphing;
public sealed class ExamplePipelineAugmenter : IRecordCanvasAugmenter, IAuthorableGraphShape
{
// Empty list = no creating from this canvas. The window still applies its own gates
// (read-only source, running pipeline, workbook-backed tab, no key column) on top.
public IReadOnlyList<string> CreatableTabs(GraphBuildContext context, string tabName)
=> new[] { "ExampleEffects", "ExampleActions" };
}Making your own edge editable — IAuthorableEdgeContributor
An edge you opened with IEdgeContributor (§4) is drawn but not editable, because only you know the notation it lives in. Add this interface to turn a gesture back into cell text; the window stages exactly what you return and the parser remains the final judge:
using SheetForge.Core.Edges;
public sealed class ModifierStatEdgeContributor : IEdgeContributor, IAuthorableEdgeContributor
{
public bool TryPlanConnect(EdgeAuthoringContext context, string fromTab, string fromRecordId,
string toTab, string toRecordId, out EdgeCellWrite write)
{
write = default;
if (fromTab != "ExampleEffects" || toTab != "ExampleStats") return false; // not mine
// CellText = the cell as it reads right now (baseline + staging), not the parsed value.
string current = context.CellText(fromTab, fromRecordId, "modifier");
if (current.Contains(toRecordId + ":")) return false; // already linked
string next = current.Length == 0 ? toRecordId + ":add:0"
: current + "; " + toRecordId + ":add:0";
write = new EdgeCellWrite(fromTab, fromRecordId, "modifier", next);
return true;
}
public bool TryPlanDisconnect(EdgeAuthoringContext context, RecordEdge edge, out EdgeCellWrite write)
{
write = default;
if (edge.FieldName != "modifier") return false;
// …remove the fragment naming edge.ToRecordId, hand back the rewritten cell…
write = new EdgeCellWrite(edge.FromTab, edge.FromRecordId, "modifier", rewritten);
return true;
}
}falsemeans nothing happens. No staging is created and the menu item is disabled with an honest reason — never a half-applied edit. Returningtruewith nonsense text is allowed but pointless: the staged value goes through the same pre-flight validation as a typed one and shows up in Problems.- Address by key, not by row.
EdgeCellWritenames (tab, record id, field); row numbers are re-resolved at write time, so a staged plan survives rows moving. - You are called during a gesture. Both methods run inside try/catch — an exception becomes an English console warning and disables that one affordance, nothing else.
- Ask the context, not the sheet.
CellTextreturns the value including staging, so two links made in a row see each other. Reading the parsed table instead would miss the first one.
Changing several cells in one gesture — IBatchAuthorableEdgeContributor
Some data keeps one item spread across parallel columns: stepDelays | stepTargets | stepCounts, where index i of each column is one step. Adding a link there has to grow every column at once, or the columns end up different lengths — a half-valid state a single-cell plan cannot avoid.
This capability is the sibling of IAuthorableEdgeContributor (not a subclass), so contributors that only have the singular form are untouched:
using SheetForge.Core.Edges;
public sealed class ExampleStepContributor : IEdgeContributor, IBatchAuthorableEdgeContributor
{
public bool TryPlanConnectMany(EdgeAuthoringContext context, string fromTab, string fromRecordId,
string toTab, string toRecordId,
out IReadOnlyList<EdgeCellWrite> writes)
{
writes = new[]
{
new EdgeCellWrite(fromTab, fromRecordId, "stepTargets", Append(context, fromTab, fromRecordId, toRecordId)),
new EdgeCellWrite(fromTab, fromRecordId, "stepDelays", AppendDefault(context, fromTab, fromRecordId)),
};
return true;
}
public bool TryPlanDisconnectMany(EdgeAuthoringContext context, RecordEdge edge,
out IReadOnlyList<EdgeCellWrite> writes) => …;
}- All of it or none of it. Every write in the list is staged as one native Undo step; if a single one cannot be written (no such row, read-only source, pipeline running) nothing is staged at all.
- Batch wins. If one class implements both the singular and the batch form, the window asks the batch form only — one gesture never has two different answers. Contributors are still asked in registration order and the first one that plans wins.
- Every write needs an address. A list containing a write with an empty tab or field (or an empty list) counts as "no plan".
- Unlinking runs on a chain. When several wires on one card are cut in a single gesture, the context you read already carries the earlier plans in this gesture, so cutting two tokens out of the same cell removes both. The singular contract has no surface to receive that intermediate value — this capability is how that limit is lifted.
- A record being created cannot be a target. In the "create and link in one gesture" flow, write addresses are resolved before the new row enters the session. A plan aimed at the record being created therefore cannot stand, and the whole gesture fails honestly. Aiming at rows that already exist (the parallel-column case) is unaffected.
Creating one more of something — IVirtualNodeFactory
When "one more" is not a new row but one more element in each of several cells, the canvas cannot invent the gesture. Declare the kinds you can make and hand back the cell writes when one is picked:
using SheetForge.Core.Edges;
public sealed class ExampleStepContributor : IEdgeContributor, IVirtualNodeFactory
{
// Called every time the node menu is built — keep it cheap and side-effect free.
public IReadOnlyList<VirtualNodeKind> KindsFor(EdgeAuthoringContext context, string tab, string recordId)
=> tab == "ExampleSkills"
? new[] { new VirtualNodeKind("step", Loc("Add a step")) } // your own translated string
: null;
public bool TryPlanCreate(EdgeAuthoringContext context, string tab, string recordId,
VirtualNodeKind kind, out IReadOnlyList<EdgeCellWrite> writes)
{
writes = null;
if (kind.Id != "step") return false; // not mine → nothing happens
writes = new[] { … }; // one element appended per column
return true;
}
}- The label is already translated. Core does not translate it — supply the string your pack resolved (see §4.14). A
/in the label makes a submenu, so you can group your own entries. tabmay be a virtual tab name or empty. Nodes your canvas override put on screen do not live in a sheet. The menu still offers what you declare, because the cells you write are named by your plan, not by the node's identity. Tabs owned by a code registry are excluded.- One Undo step, all or nothing — the same rule as the batch capability above.
falsestages nothing at all.
Declaring connect slots — IEdgeSlotDeclarer
Connect slots normally come from the schema (RecordId@Tab columns). A node your override put on screen has no columns, and a contributor edge only reveals a slot once a link already exists — so the first link had nowhere to start. Declare the slots instead:
using SheetForge.Core.Edges;
public sealed class ExampleStepContributor : IEdgeContributor, IEdgeSlotDeclarer, IBatchAuthorableEdgeContributor
{
// Called per card and per port gate — keep it cheap and side-effect free.
public IReadOnlyList<DeclaredSlot> DeclareSlots(EdgeAuthoringContext context,
string nodeTab, string nodeRecordId)
=> nodeTab == "#step"
? new[] { new DeclaredSlot("target", "ExampleEffects", /*isList*/ false) }
: null;
}- The name has two duties. It must be unique within that node, and it must equal the
FieldNameof the edge you draw into it — slot lookup and wire anchoring both match on that name. If a sheet column already has that name, the sheet wins and your declaration is quietly dropped. - Declaring is not planning. A declared slot is connected through your plan (
IAuthorableEdgeContributoror the batch form). Declare without planning and the port opens but nothing is staged — implement both. - Ports open on nodes with no sheet row. For a node whose tab is not a sheet, the window does not look for a row by that name; the write address comes from your plan and is checked at staging time.
Editing what the token says — IEdgeTokenEditor
Linking and unlinking move a whole token. Often the token is more than a key: attack:add:10 names a stat and how much. Add this capability to the same contributor and the wire inspector gains one row for that leftover — the part that is not the key:
using SheetForge.Core.Edges;
public sealed class ModifierStatEdgeContributor : IEdgeContributor, IAuthorableEdgeContributor, IEdgeTokenEditor
{
public bool TryDescribeToken(EdgeAuthoringContext context, RecordEdge edge,
out EdgeTokenDescription description)
{
description = null;
if (edge.FieldName != "modifier") return false; // not mine
// Read the fragment out of the cell — never rebuild it from the edge, or the
// highlight points at a piece that is not there.
string fragment = FindFragment(context.CellText(edge.FromTab, edge.FromRecordId, "modifier"),
edge.ToRecordId);
if (fragment == null) return false; // hand-edited away
description = new EdgeTokenDescription(
/*tokenText*/ fragment, // "attack:add:10"
/*modifierText*/ fragment.Substring(fragment.IndexOf(':') + 1),// "add:10"
/*modifierLabel*/ "op:value",
/*isChoice*/ false, /*options*/ null, /*optionLabels*/ null); // free text
return true;
}
public bool TryPlanSetModifier(EdgeAuthoringContext context, RecordEdge edge,
string newModifier, out EdgeCellWrite write)
{
// …rebuild the cell with that one fragment's leftover replaced, key untouched…
}
}- Both halves read the same cell. An edge knows where it points, not what letters it is written in today, so describing takes the same
EdgeAuthoringContextthe write takes. That is what makes the highlighted fragment and the rewritten fragment provably the same one. - The key never moves through this door. Changing what a link points at is re-aiming (drag the wire); this row only changes the leftover. Returning
falsefrom either half hides or honestly disables the row — no staging, no silent failure. - The widget is yours to describe.
isChoicewith options draws a popup, otherwise a text field; the row's label and the option labels are your strings. If there is no leftover at all, constructnew EdgeTokenDescription(tokenText)and the row is not drawn — a core reference (whose key is the whole token) behaves this way with no code at all.
Making your own wires editable at all
A wire can only be edited if it names the cell it is written in. The core fills that in for the references it reads itself; an extra edge you add (§4.7) does it by naming the field:
// Display-only edge — the canvas honestly reports it cannot be edited.
builder.AddEdge(tab, recordId, targetTab, targetKey, "raises");
// Edge that names its cell: "this link is written in (tab, record, column)".
builder.AddEdge(tab, recordId, targetTab, targetKey, "listen", /*fieldName*/ "listen");Naming a cell does not promise it is editable — it says where the link lives. An edge you added is handed to the same plumbing a contributor edge uses, so it becomes editable exactly when an IAuthorableEdgeContributor claims it. If that column is a plain text or enum column with nobody to rewrite it, the canvas reports the wire as not editable here, which is the truth rather than a silent no-op.
4.13 Custom cell widgets (opt-in, Editor assembly)
The grid draws every cell with a built-in widget (boolean toggle, enum popup, reference picker, raw text). When a type deserves a better input — a curve, a colour, a mini-grammar composer, a multi-line box — replace the widget for that type name without touching how the value is parsed:
using SheetForge.Editor.Studio;
using UnityEngine.UIElements;
public sealed class ModifierCellEditor : IStudioCellEditorProvider
{
// The base type name from @type (a CellParserRegistry name; for a wrapper, the wrapper name).
public string TypeName => "Modifier";
public VisualElement CreateEditor(StudioCellEditorContext context)
{
if (context.Type.IsList) return null; // decline — the built-in widget takes this cell.
var field = new TextField { value = context.CurrentRawText };
// Typing burst: coalesced into ONE Undo step for this cell.
field.RegisterValueChangedCallback(e => context.CommitTyping(e.newValue));
// Discrete confirmation (focus out): its own Undo step.
field.RegisterCallback<FocusOutEvent>(_ => context.Commit(field.value));
return field;
}
}- The widget shapes input, the parser owns meaning. Whatever you commit is canonical sheet text; it goes through the same pre-flight validation as a typed value, and problems surface in the Problems panel. The widget never needs to validate.
- Two commit surfaces, on purpose.
Commit(picking from a list, releasing a slider, focus out) creates one Undo step;CommitTyping(per-keystroke) coalesces a burst into one step. Collapsing them into one call would either spray Undo steps per letter or merge two distinct picks. - Returning
nulldeclines the cell and the built-in widget takes over — the honest answer for shapes you don't handle (List<T>of your type, optional fields).context.Type(the parsed@typetoken) carries everything needed to decide. ReferenceKeys(tab)hands you the same candidate list the built-in reference picker uses: projected keys ∪ code-registry keys ∪ staged new-row keys, sorted. There is no need to gather your own. To let the person choose from that list in the same drop-down the built-in cell opens, callStudioKeyPicker.Show(screenAnchor, tab, candidates, picked)and splice the returned key into your own notation before committing. (Creating a record, leaving the cell empty and multi-toggling a list are the built-in reference cell's own rules and are not on that facade — a widget that owns the whole cell text owns those decisions too.)- You may claim a built-in type name, not just your own. The registered-widget branch runs first, so
TypeName => "float"really does replace the raw-text box for everyfloatcolumn. That is how a slider, a percentage field or a unit-suffixed box gets in. Two cautions come with it:- It applies to every column of that type in the project, so scope it by reading
context.FieldName/context.Taband returningnullfor the columns you did not mean. - What you commit is still canonical sheet text, so a slider must render its value the way the parser reads it back (see
CanonicalValueRenderer.RenderFloatfor the float spelling the round trip expects).
- It applies to every column of that type in the project, so scope it by reading
- Conflicts warn, discovery is automatic. Same
TypeCachediscovery as every other contract; if two providers claim one type name the first found wins and a console warning names both. A thrownCreateEditoris caught, warned, and the cell falls back to the built-in widget. - Before writing one, check whether a hint would do. If all you want is a dropdown, a multi-line box, a slider, a toggle, a colour picker, a curve editor or a gradient editor, register a
StudioCellEditorHintinstead (§4.16) — no widget code, and it works in the browser too. The order is: this contract first, then the hint, then the core defaults; so a hint is what the cell gets whenever no widget has claimed the type or the one that did declined.
4.14 Plugin UI strings (opt-in)
Labels your pack shows — inspector actions, widget captions, the declarative surfaces of §4.16 — can follow the user's language. Register sentences per language key; Loc.Tr consults this overlay before the product tables, and the browser's t() does the same:
using System.Collections.Generic;
using SheetForge.Core.Model;
using SheetForge.Core.Plugins;
public sealed class ExampleLocStrings : ISheetForgeStringsPlugin
{
// Prefix keys with your pack name so packs never collide.
public const string BrakeActionKey = "plugin.skillsDemo.action.setBrake";
public void RegisterStrings(StringOverlayRegistry strings)
{
strings.Register(BrakeActionKey, new Dictionary<string, string>
{
{ "en", "Set reaction brake to 0.25s" },
{ "ko", "반응 제동을 0.25초로 넣기" },
});
// Or one language at a time: strings.Register(key, "en", "…");
}
}- This contract lives in Core, so put it in your main assembly. Both hosts show your pack's labels, and the browser only ever loads the main DLL — a strings plugin sitting in the editor companion assembly would leave the web app showing raw keys.
- Registering is optional. An unregistered key keeps displaying verbatim — this contract is an upgrade path, not a requirement.
- Languages are IETF codes (
"en","ko","zh-Hans","pt-BR", …), matched case-insensitively.- Register English at minimum: the lookup falls back requested language → English → miss, so a user in any other language reads your English sentence rather than the raw key.
- A code the product does not know is rejected with a reason rather than being folded into English — a typo that silently became English would be untraceable.
- Product keys cannot be overridden — a registration that names a built-in key is refused, so an overlay can never make the UI disagree with the product's own sentences. Menu labels in particular are baked from the language tables directly, so an overlay that could rewrite them would make the guidance text and the real menu path disagree. The overlay is for new keys.
- Duplicate registrations across packs keep the first found, with a reason recorded — if the last registration quietly won, the screen would depend on plugin install order.
- Empty keys and empty values are refused too. Every refusal is a developer-facing English line, because the audience is the plugin author, not the end user.
- The product's 10-language parity rule is untouched: your strings live in a lookup overlay beside the core tables, never inside them.
(The demo ships this at Assets/SheetForge.PluginDemo/ExampleLocStrings.cs — in the main assembly, for the reason above — registering the labels its inspector action (§4.10) and declarative surfaces (§4.16) display.)
4.15 Multi-line text in one cell (dialogue, descriptions, scripts)
A real newline can never live inside a cell. The pipeline's input is TSV, where a tab separates cells and a newline separates rows, so a cell holding either character has no representation at all.
Every source enforces this at the door rather than letting a corrupted grid through:
- the CSV and xlsx readers report
UnsupportedCellCharacterwith the cell's coordinate, collecting every offending cell, not just the first; - the Google fetch does the same;
- and in a
.tsvfile the character was already the row separator.
This is a design constant of the format, not a gap waiting to be closed. So a domain with long text works with it, through a three-part convention that is entirely inside plugin territory.
1. Pick an escape and write it into your parser. The conventional choice is a literal two-character \n in the sheet, unescaped on the way in and re-escaped on the way out:
public sealed class ProseCellParser : ICellValueParser, ICustomCellType
{
public string TypeName => "Prose";
public Type ValueType => typeof(string);
public bool TryParse(CellParseContext ctx, string text, out object value)
{
value = text.Replace("\\n", "\n"); // sheet spelling → the value your game sees
return true;
}
public bool TryRender(object value, out string text, out string reason)
{
reason = null;
text = ((string)value).Replace("\r\n", "\n").Replace("\n", "\\n"); // the exact reverse
return true;
}
}Make the two directions exact inverses and prove it. TryRender is what Export and Push write back, so if it does not undo TryParse character for character, a "sheet → import → export → sheet" round trip rewrites text nobody edited. Normalising \r\n to \n on the way out (as above) is what keeps a Windows-authored value from alternating between two spellings on successive exports. A single test that renders a parsed value and compares it to the original cell text is enough to lock it.
2. Give the cell a real editor. A \n-escaped value is unpleasant to type in a one-line box, which is exactly what §4.13 is for. Register an IStudioCellEditorProvider for "Prose" that returns a multi-line TextField (multiline = true), showing the value with real newlines and committing it re-escaped. Commit on focus-out with Commit (one undo step per edit session) rather than per keystroke.
3. Know the one place the convention does not reach. Someone typing Alt+Enter directly in the Google Sheet creates a genuine newline in the live cell, and that cell is refused on the next fetch with a coordinate pointing at it. The refusal is honest and fixable, but it is a refusal. So if the writers on your team author prose in the spreadsheet itself, say in your own documentation that long text is written with \n. Or let them author it in the Data Studio cell widget from step 2, where the escaping happens for them.
4.16 Declarative authoring surfaces (opt-in)
§4.9, §4.10 and §4.13 hand back a VisualElement, which is exactly why they are editor-only: the browser cannot load a UIToolkit type, so an extension written that way exists on one screen and not the other.
This contract answers the same needs as data. You describe the shell — an id, a label key, a placement, a tone — and supply only the predicate and the effect as delegates. One registration is then drawn by the editor's UIToolkit renderer and by the browser's React renderer alike.
using SheetForge.Core.Plugins;
using SheetForge.Core.Studio;
using SheetForge.Core.Theming; // ThemeColorSlot — tones are slots, never hard-coded colours
public sealed class ExampleStudioUi : ISheetForgeStudioPlugin
{
public void RegisterStudioUi(StudioUiRegistry ui)
{
// ① A verb — right-click a row, and this appears at the end of the menu.
ui.AddAction(new StudioActionDescriptor(
"skillsDemo.setBrake", // unique id ("pack.verb" reads well)
ExampleLocStrings.BrakeActionKey, // a Loc key (§4.14); unregistered = shown verbatim
StudioActionPlacement.RowContextMenu,
ctx => ctx.Tab == "ExampleActions" && !string.IsNullOrEmpty(ctx.RecordId), // cheap predicate
ctx => ctx.StageCell(ctx.Tab, ctx.RecordId, "brakeSeconds", "0.25")));
// ② A summary panel — a node tree, rebuilt each recompute tick.
ui.AddPanel(new StudioPanelDescriptor("skillsDemo.summary", ExampleLocStrings.PanelTitleKey, ctx =>
StudioUiNode.List(
StudioUiNode.Heading("Cast summary"),
StudioUiNode.KeyValue("Total damage", TotalDamage(ctx).ToString()),
StudioUiNode.Progress("Cast time", CastRatio(ctx), ThemeColorSlot.Accent),
StudioUiNode.Button("Fill every unbraked reaction", "skillsDemo.fillBrakes"))));
// ③ A column badge — one node beside a column header (null = nothing on that column).
ui.AddColumnBadge(new StudioColumnBadgeDescriptor((ctx, tab, field) =>
field == "brakeSeconds" ? StudioUiNode.Badge(UnbrakedCount(ctx) + " unbraked", ThemeColorSlot.Warning) : null));
// ④ A cell-editor hint — pick a built-in widget for your type without writing one.
ui.AddCellEditorHint(new StudioCellEditorHint("Modifier", StudioCellEditorArchetype.Dropdown, Options));
}
}The vocabulary is deliberately bounded — it grows only by appending, never by inserting, so an existing registration keeps its meaning.
- Five placements for an action:
Inspector,RowContextMenu,TopbarMenu,ColumnHeaderMenu,CanvasNodeMenu.- Each fills the context with what that seat knows — the row placement carries the record, the column placement carries the column name, the canvas placement carries the node's record — and leaves the rest empty, so guard before you read a field a seat does not supply.
- Thirteen node kinds for a panel or badge:
Row,Label,Chip,Badge,Button,Rule,Heading,KeyValue,Table,List,Progress,Input,Link.- They are built through static factories (
StudioUiNode.Label(…),.WithTooltip(…)), so a node is immutable and only the fields that mean something for its kind are set.
- They are built through static factories (
- Seven cell-editor archetypes:
Dropdown(you supply the candidates),MultilineText,Slider(you supply the range),Toggle(you supply the two canonical texts),ColorPicker(#RRGGBB/#RRGGBBAA),CurveEditorandGradientEditor(the cell text is the canonical curve / gradient notation from Sheet Syntax — a pack whose own type writes that notation, for example throughCurveValue.Render(), may declare them). The built-inColor,AnimationCurveandGradienttypes are wired through the very same mechanism —BuiltinCellEditorHintsholds their three hints — and a host consults a pack's registrations first, so registering a hint under one of those type names overrides the built-in choice rather than being refused. In the editor the last three archetypes are Unity's colour, curve and gradient fields; in the browser they are the app's own editors; aList<>of a type carrying one of them becomes a chip editor in both. The Plugin Demo'sFallofftype does exactly that: its parser reads the cell withCurveValue.TryParse, and one hint registration gives it a curve field in Unity and the curve editor in the browser. - No layout numbers anywhere. Pixels and ratios would leak one screen's grain into the other; you say what to show and each renderer decides how to place it.
Rules worth knowing before you write one:
- Mutation goes through the same door your hand does.
StudioSurfaceContextgives an action exactly four powers —StageCell,StageCells(several cells, one Undo step, all-or-nothing),FocusRecord,RequestRebuild— on top of the read-onlyTables/References/CodeRegistries.- So a plugin's verb is an ordinary staged edit: one
Ctrl+Zstep, nothing reaches the sheet until you push, same pre-flight. - The staging gate applies too — a read-only source, a running pipeline or a workbook-backed tab blocks it with the reason shown.
- So a plugin's verb is an ordinary staged edit: one
- Predicates run constantly.
AppliesTo, panel building and badge provision run on every gesture and every recompute tick. Read the snapshot you were handed; no IO, no network, no long computation. - Displayed is not executed. The host re-checks the predicate at invocation. If the situation changed since the menu was drawn, the answer is an honest no-op plus a redraw rather than a second failure. The browser does the same for a stale id.
ConfirmKeyasks first. Give an action a confirmation key and the host shows that sentence before running it — the right thing for a verb that stages many cells at once.- A
Linknode openshttp/httpsonly. The rule is one Core predicate (StudioUiNode.IsAllowedUrl) that both hosts ask, so they cannot disagree about what is safe to open; the browser then re-checks the same shape before it renders an anchor, which can only refuse more, never less.- The url is stored exactly as you wrote it and refused at the opening end with a reason, rather than being scrubbed at registration time — the pack that wrote it should be able to find out why nothing happened.
- Panels hold no state. They are rebuilt each tick; the only place a value belongs is the sheet (staged). If nothing registers a panel, the pane is not drawn at all.
- Exceptions are isolated — a throw becomes an English console warning and removes that one affordance, not the window.
When description is not enough — IStudioPanelProvider (Editor assembly)
Arbitrary rendering, composite input and multi-step flows have no vocabulary here, and inventing one would mean maintaining a miniature UI framework forever. So the ceiling is deliberate and the escape hatch is wide: implement IStudioPanelProvider in your editor companion assembly and paint whatever you like.
using SheetForge.Editor.Studio;
using UnityEngine.UIElements;
public sealed class ExampleStudioPanel : IStudioPanelProvider
{
public string Id => "skillsDemo.summary"; // same id as the descriptive panel above
public string TitleKey => ExampleLocStrings.PanelTitleKey;
public bool AppliesTo(StudioSurfaceContext context) => context.Tab == "ExampleSkills";
public VisualElement CreatePanel(StudioSurfaceContext context) => new Label("…anything…");
}Register both under the same Id and each host takes what it can draw: the editor uses the rich one, the browser uses the descriptive one. That is how "as far as the browser goes, all the way in the editor" holds without a second set of contracts.
There is no web-only variant — a missing rich panel means the descriptive one is drawn, not that the panel disappears. The element lives one recompute tick, so it holds no state either.
4.17 Watching the pipeline (opt-in)
A cross-product bridge, domain telemetry or a follow-on generator often needs to know what an import produced without re-parsing it. Implement IPipelineObserver and register it through ISheetForgePipelinePlugin:
using SheetForge.Core.Model;
using SheetForge.Core.Plugins;
public sealed class ExampleImportObserver : IPipelineObserver, ISheetForgePipelinePlugin
{
public void RegisterPipelineObservers(PipelineObserverRegistry observers) => observers.Register(this);
public void OnImportCompleted(PipelineRunView view)
{
// view = Success · Tables · Diagnostics · SkippedTabs · EnumTabs — an immutable snapshot.
if (!view.Success) return;
// … cache what you need; do not hold the tables ...
}
}- Observing cannot change the outcome. You receive one immutable snapshot and return nothing. There is deliberately no hook to alter a value or add a diagnostic: interpreting a value belongs to a cell type (§2) and reporting a rule violation belongs to a domain validator (§3). Mixing participation into an observation contract would make "observers cannot change the result" false in practice.
- Once per explicit import cycle, at its end, whether it succeeded or failed. It does not run on the pre-flight projection that recomputes while you stage — no third-party code is attached to keystroke frequency.
- A failed run still reports what it parsed.
Tablescarries the tabs that parsed before validation failed, which is the same material the quarantine flow uses (Data Studio), so an observer sees a truthful picture of a failed run rather than nothing at all. - Two honest gaps. The observer fires from the import cycle's own completion point, so a run that never reaches it does not fire at all.
- An import aborted before the pipeline runs (no active settings, the Addressables gate refusing).
- The codegen→compile leg being interrupted by a compile error.
- That is a zero-fire, never a wrong-fire: if you need "an import was attempted", pair this with the editor-side
ImportEventsbus.
- A throw is isolated to that observer, with the reason collected; the import's output does not change by a bit.
- Future observation points (right after parsing, an export cycle) will arrive as sibling capability interfaces discovered by casting the registered observer, so adding one will not break an implementation written today.
5. Custom import sources (ISheetSourceProvider)
A new source (database, REST endpoint, in-house format) joins with zero Core/Editor edits. Implement ISheetSourceProvider in an Editor assembly. SourceProviderRegistry discovers it via TypeCache, and it appears in the settings "Source" dropdown alongside the built-ins.
The four things a provider answers:
- Fetch —
CreateTabSource(settings)returns anITabSourcesupplying tab name → raw TSV text (async; environment problems are diagnostics, not exceptions; partial output allowed). - Write-back —
CreateReflectTarget(dispatcher, settings)returns anISourceReflectTargetthat plugs into the authoring dispatcher (use the dispatcher's publicSession/Callbacks/Baselinesto assemble your target). Return a target only if your source can be written. - Visibility —
GetVisibility(settings)returns which settings fields the inspector should show for you. CanAuthor— returnfalsefor read-only sources; the authoring windows disable their editing UI (same as Google ExportUrl).
The stable string Id persists in sourceProviderId. The built-ins use "LocalFile" / "GoogleSheet" as their Ids; an empty sourceProviderId resolves to the built-in LocalFile default. An empty Id opts the provider out of the UI (useful for test probes).
Providers live in the Editor assembly deliberately — sources are the IO boundary, and keeping IO out of Core preserves its purity (the other three contracts are pure Core).
5.5 Public tools for automation and integration
Beyond the registration contracts, five public entry points exist for code that drives SheetForge rather than extending it — a CI script, a build hook, your own inspector button, or a second product that bakes its own assets from the same sheets.
Run a cycle — SheetForge.Editor.Pipeline.SheetForgeActions:
SheetForgeActions.RunImport(); // exactly what the toolbar's "Pull from source" does
SheetForgeActions.RunExport();
SheetForgeActions.RunPush();
SheetForgeActions.RunHealthCheck();Each call is the whole cycle: settings resolution, the Addressables gate, mutual exclusion, confirmation and approval modals, the progress bar, and the codegen→compile→bake resume across the domain reload. There is no half-cycle to assemble and therefore no gate to skip by accident.
Two things to know:
RunImportandRunPushare fire-and-forget — their bodies areasync voidbecause the editor main thread must not block on network IO. The return is therefore not completion; subscribe toImportEvents.ImportCompletedfor that.- Push still shows its approval modal, so an unattended script cannot send without a person.
Take the same lock the built-ins take — for a custom source provider writing to its own backend:
if (!SheetForgeActions.TryBeginExclusiveScope(out IDisposable scope)) return; // something is running
using (scope) { /* write to your source */ } // Dispose releases; a second Dispose is harmless
// schedule any re-import AFTER the scope closes — the lock is not re-entrantSheetForgeActions.IsBusy answers the same question without taking anything. The lock itself stays internal on purpose: if it were public, calling its End() could release somebody else's run — the scope makes that impossible, because only the holder can release.
Finish a write-back the way the built-ins finish — AuthoringDispatcher.FinalizeReflectSuccess(writtenTabs) runs the ending an ISourceReflectTarget has to reach:
- retain-pruning for the tabs it wrote,
- the
ClearUndoconfirmation boundary, - and the automatic re-import.
The built-in local and Google paths run the same body, so your provider ends identically instead of approximating it. BuildProjectedTabs() beside it hands you the projection as TSV per tab — what you are about to send — so a provider can preview or transform it without writing. An empty writtenTabs list is a no-op that keeps staging intact.
Show the product's own sentences in your own UI — ImportReportText.Render(report) (Core.Tooling) returns the human-readable report as a string with nothing written to the console; SheetForgeActions.RenderReportText(report) is the same thing in the user's current editor language. Use it with AuthoringDispatchCallbacks.RenderReport so a second authoring surface reports failures in exactly the words the product uses.
Enumerate a baked tab without knowing its generated type — DefinitionDatabase.RecordsUntyped:
foreach (DefinitionDatabase db in myBakedDatabases)
foreach (object record in db.RecordsUntyped) // reflect on the fields you care about
;This is the sanctioned path for a second baker (a different product turning the same sheets into its own assets). Do not reflect on the private records field: doing so turns a field name into an undeclared contract that breaks silently the day codegen renames it. The list is read-only — the sheet is canonical. It defaults to empty on generated code written before this member existed; one re-import emits the override.
Extend the generated classes safely — both generated classes are partial, so a derived member (a computed property, an interface implementation, an operator) can live in your own file beside them and survive every re-import. Add no serialized fields there: the baked ScriptableObject is rebuilt from the sheet on each import, so a field only your part serializes comes back at its default. If a value belongs to the data, it belongs in a column.
What stays closed — on purpose
The surfaces above are the sanctioned outer edge. The following stay internal no matter how convenient opening them would look, because each is a trust or integrity boundary, not a convenience boundary:
- Credentials and signing — the service-account key locator, the JWT/PEM/PKCS8 primitives and the Google access-token provider. Opening them would hand any plugin a bearer token scoped to your spreadsheet.
- The raw push chain (push runner, sheet gateways, cell writes) — approval (
IPushApprover) is enforced inside that orchestration; a public raw writer would be a sheet write with no approval step. - Pre-send verification and the reflect write engines — external code enters through
AuthoringDispatcher.Reflect()only, which passes stale-anchor checks, preflight validation and approval on the way; the writing engine underneath is not a contract. - The bake/codegen integrity chain (schema fingerprints, generated-source writer, orphan cleanup) and the build freshness gate — opening them would make forging or bypassing bake state a one-liner.
- The ephemeral SO overlay — "the sheet is the source of truth" has exactly one sanctioned exception (the inspector test-edit toggle), and it is deliberately not offered as API.
If a workflow seems to need one of these, it needs a feature request, not reflection.
6. Generated code placement and namespaces
generatedCodeFoldermay be any folder (companion-asmdef self-healing wires plugin-type references automatically), but placing it inside your package (e.g.Assets/MyDomain/Runtime/Generated) is tidiest. Generated types then compile in the same assembly as your enums/custom types with no companion asmdef needed.- Per-tab home: a tab whose generated type already exists somewhere is regenerated in place — your package's committed
Generatedfolder stays authoritative even if the settings point elsewhere. Stale duplicates are auto-cleaned (logged, never silent). generatedNamespaceisolates your generated types (e.g.MyGame.Data). Type discovery uses the generated types' intrinsicSchemaFingerprintmarker, not the namespace, so any namespace works. Changing the value auto-triggers regeneration.- Whether to commit your package's
Generatedfolder is your package's policy. The sample commits its (default-namespaceExample*classes inSheetForge.Generated, tabsExampleSkills/ExampleEffects/ExampleActions) so a fresh clone compiles immediately. TheExample*class-name prefix — not a separate namespace — is what keeps them from colliding with your project's realSkills/Effectstabs.
7. Consuming at runtime — "assemble, don't script"
Your runtime reads the generated databases and dispatches on the type enum to code atoms:
using SheetForge.Runtime;
using SheetForge.Generated;
var hSkills = SheetForgeDatabases.LoadAsync<ExampleSkillsDatabase>("ExampleSkills");
var hActions = SheetForgeDatabases.LoadAsync<ExampleActionsDatabase>("ExampleActions");
var hEffects = SheetForgeDatabases.LoadAsync<ExampleEffectsDatabase>("ExampleEffects");
var runner = new SkillRunner(await hSkills.Task, await hActions.Task, await hEffects.Task);
// keep the handles for the system's lifetime; Release each on shutdownFor truly procedural one-off logic, reference a script asset via AssetRef — SheetForge validates the reference and bakes the addressable (exactly like an image); executing it is the game's job.
8. Detecting SheetForge from another asset
A different asset — one that integrates with SheetForge rather than extends it (a stat system, say) — can detect that SheetForge is installed. Because a paid Asset-Store product is a folder product (no package.json / UPM), it cannot ship a versionDefines entry. Instead SheetForge's Editor assembly self-registers a SHEETFORGE scripting-define symbol on every build target.
(a) Compile-time (preferred):
- If your integration lives in its own assembly definition, add
SHEETFORGEto that asmdef's Define Constraints — the assembly then compiles only when SheetForge is present. - If SheetForge-touching code shares an assembly with code that must compile regardless, guard just those parts with
#if SHEETFORGE … #endif.
(b) Editor-time (alternative): when you cannot rely on compile order, probe by reflection — e.g. System.Type.GetType("SheetForge.Editor.Pipeline.ImportEvents, SheetForge.Editor") != null — then wire up (for instance) the completion bus dynamically.
SHEETFORGE means "SheetForge is installed". It is separate from SHEETFORGE_ADDRESSABLES, an internal version-define on SheetForge's own assemblies that only marks whether the Addressables package is present — do not use the latter as an install probe.
The define persists if SheetForge is later removed (there is no watcher to un-set it); remove it by hand in Project Settings ▸ Player. See Capabilities & Limits.
What still needs Core edits
Everything above joins with zero Core edits. What a plugin still cannot do without Core changes:
- Emit marker values into generated code — custom markers are validation/display metadata; baking them into codegen constants or attributes is out of scope until a consumer needs it.
- Have key-rename propagation reach inside a custom notation without being told how — a renamed record is rewritten in
RecordId@Tabcells, lists of them, and wrapper elements on the Core's own. For your own grammar, implementIReferencingCellType(§4.4a) and it is rewritten with the payload preserved; that is an opt-in, not a Core edit. Decline the opt-in and the boundary stands: your domain validator reports the dangling key rather than the rename silently fixing it. - Add members to a plugin-registered C# enum from a sheet — an enum registered with
enums.Register<T>()is owned by code, so an enum definition sheet cannot extend it and the Data Studio does not offer the row. Move the enum into an enum sheet if the sheet should own it (see Sheet Syntax).
The <> wrapper type (ICellWrapperType, see §2) and the custom structural marker (IStructuralMarkerDefinition, see §4.5) both extend the pipeline without Core edits.
Related pages
- Sheet Syntax — how registered types appear in sheets
- Data Studio — where the canvas overrides, code registries, widgets and actions show up
- API Reference — every contract's full signature
- Authoring Kernel — edges and the engine surface
- Capabilities & Limits — the plugin-extension boundaries (wrapper rejection rules, marker limits) and reserved seams