Autoria de Plugins — Adicione um Domínio com Zero Edições no Core
Um domínio (skills, itens, quests, …) se integra ao SheetForge como um pacote separado que referencia SheetForge.Core — o Core nunca referencia ele de volta.
Um plugin pode adicionar enums, tipos de célula personalizados, tipos wrapper, validadores de domínio, arestas de grafo, marcadores estruturais, templates de "Criar planilha", origens de importação inteiras, as sobreposições de canvas do Data Studio, registros de código, superfícies de autoria declarativas, widgets e ações, predefinições de cor, widgets de célula personalizados, observadores de pipeline, e as suas próprias strings de UI localizadas — os dezesseis contratos abaixo.
"Adicionar um domínio = zero linhas do Core alteradas" é imposto pelo compilador. Um assembly de teste sem InternalsVisibleTo (SheetForge.Tests.Consumer) implementa quinze dos dezesseis — e as interfaces de capacidade ao lado deles — usando apenas a superfície pública. Se qualquer um deles fosse restringido a internal o build falharia (CS0122). O décimo sexto, a válvula de escape de painel rico somente-editor, retorna um VisualElement e por isso é exercitado por um teste do lado do editor em vez disso.
Os onze contratos Core são C# puro. É isso que permite que uma única DLL de plugin compilada acenda os mesmos slots no editor da Unity e no navegador (SheetForge Web) — a montagem e o isolamento são uma única função Core compartilhada, e só a descoberta difere por host (o TypeCache da Unity, a varredura de assembly enviado do navegador).
Os cinco contratos Editor devolvem elementos UIToolkit ou tocam o estado da janela, então existem somente no editor.
Todos os dezesseis são descobertos automaticamente — um construtor sem parâmetros é toda a exigência, sem referência de assembly, chamada de registro ou manifesto para editar:
| Contrato | Registra | Opcional? |
|---|---|---|
ISheetForgePlugin | Enums + parsers de tipo de célula personalizado | O contrato base |
ISheetForgeValidatorPlugin | Regras de validação de domínio (entre colunas / entre abas) | Complemento opcional |
ISheetForgeEdgePlugin | Declarações de aresta de grafo que o scanner do core não consegue ver | Complemento opcional |
ISheetForgeMarkerPlugin | Marcadores estruturais personalizados (linhas @marker por coluna) | Complemento opcional |
ISheetForgeTemplatePlugin | Templates de "Criar planilha" (abas + dados de exemplo) | Complemento opcional |
ISheetForgeGraphPlugin | Sobreposições de canvas por aba para o Data Studio | Complemento opcional |
ISheetForgeCodeRegistryPlugin | Espaços de chave somente leitura que vivem no código, como abas virtuais travadas | Complemento opcional |
ISheetForgeThemePlugin | Predefinições de cor para as janelas do SheetForge (escura e clara) | Complemento opcional |
ISheetForgeStudioPlugin | Superfícies de autoria declarativas — ações, painéis, selos de coluna, hints de editor de célula | Complemento opcional |
ISheetForgeStringsPlugin | As strings de UI do seu pacote, por idioma (uma sobreposição consultada antes das tabelas do produto) | Complemento opcional |
ISheetForgePipelinePlugin | Observadores de pipeline — notificação somente leitura do que uma importação produziu | Complemento opcional |
ISheetSourceProvider | Uma origem de importação inteira (DB / REST / interna) | Independente (assembly Editor) |
IStudioGraphWidget | Um widget de domínio acima do canvas do Data Studio | Independente (assembly Editor) |
IStudioInspectorAction | Um botão extra no inspector de nó do Data Studio | Independente (assembly Editor) |
IStudioCellEditorProvider | Um widget de entrada personalizado para um tipo de célula na grade do Data Studio | Independente (assembly Editor) |
IStudioPanelProvider | Um painel UIToolkit arbitrário no Studio — a válvula de escape ao lado da declarativa | Independente (assembly Editor) |
O exemplo de referência é uma importação seletiva. O exemplo completo (
SheetForge.PluginDemo) é distribuído como um pacote Unity emAssets/SheetForge/Examples/SheetForgePluginDemo.unitypackage— dê duplo clique nele, ou pressione Importar Plugin Demo na janela Primeiros Passos (Tools ▸ SheetForge ▸ Getting Started, o único lugar onde as importações de demonstração vivem), para restaurá-lo emAssets/SheetForge.PluginDemo/….Até que você o importe, ele não está no seu projeto de jeito nenhum — o exemplo é distribuído apenas como esse pacote — então seus assemblies/tipos/abas/endereços nunca colidem com o seu. Os caminhos referenciados abaixo (
Assets/SheetForge.PluginDemo/ModifierCellParser.cs, etc.) existem assim que você tiver importado o pacote.(Um segundo exemplo, sem plugin —
SheetForge.CoreDemo— demonstra o pipeline usando apenas tipos embutidos do core.)
Os complementos estendem a interface base sem alterá-la — um plugin que não precisa de validação ou arestas permanece intocado pela existência deles.
Sete outras interfaces são capacidades, e não contratos:
- Elas não são descobertas por conta própria.
- São implementadas adicionalmente por algo já registrado.
- O Core as encontra fazendo um cast daquele objeto registrado.
Seis são convertidas via cast a partir de um contribuidor de aresta ou uma sobreposição de canvas já registrado — veja §4.12 para a regra de descoberta e cada uma delas. A sétima, IReferencingCellType, é convertida via cast a partir de um parser de célula registrado. Ela dá à sua própria notação o mesmo tratamento de referência que RecordId@Tab recebe — veja §4.4a. Ignorar qualquer uma delas não muda nada.
1. Configuração do pacote
Crie uma pasta com seu próprio .asmdef referenciando SheetForge.Core (mais SheetForge.Runtime se você precisar de busca em tempo de execução). É só isso. O PluginRegistry do Editor descobre sua implementação de ISheetForgePlugin via TypeCache e chama seus métodos de registro, e o registro é seu código explícito, não uma varredura de assembly.
Mantenha as implementações de contrato Core naquele assembly principal. Um assembly complementar do lado do editor (referenciando também SheetForge.Editor) é onde vão as cinco implementações IStudio* / ISheetSourceProvider — o navegador carrega apenas a sua DLL principal, então um contrato Core implementado no complemento de editor ficaria silenciosamente ausente ali.
1.1 Declarando compatibilidade (opcional, uma linha)
Um atributo em nível de assembly declara para qual geração do formato de plugin o seu assembly foi construído, e o host mínimo que ele quer:
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- Omiti-lo é aceitável. Um assembly sem declaração é lido como a geração
SheetForgePluginFormat.Minimumsem exigência de host, então plugins escritos antes de o atributo existir carregam exatamente como sempre carregaram. - A unidade de julgamento é o assembly, e um assembly recusado perde todos os seus registros. Uma declaração por tipo deixaria passar um tipo vizinho não declarado e te deixaria com "recusado, mas metade registrou".
- A DLL é a juíza, não o catálogo. O registro do market anuncia os mesmos dois valores (
pluginFormat,minHost) para que uma listagem possa ser filtrada antes do download, mas o portão lê o atributo a partir dos bytes verificados — uma listagem pode estar errada, a declaração compilada não pode. - A recusa é um diagnóstico
PluginIncompatiblenomeando o que o assembly declarou e o que este host lê, não um desaparecimento silencioso. Isso é uma declaração de compatibilidade, não uma assinatura: a integridade é trabalho do canal de distribuição (veja Mercado de Plugins Web). - O número de geração só muda se o próprio formato de plugin for substituído. Crescimento puramente aditivo — um novo contrato, um novo membro em um registro — nunca o move, porque o seu plugin existente continua rodando sem uma recompilação.
2. O plugin base: enums + tipos de célula personalizados
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());
}
}Um tipo de célula personalizado, do início ao fim
Implemente ICellValueParser (string → valor). Para completar o bake fortemente tipado e o round-trip de Exportação/Push, também implemente ICustomCellType (tipo CLR + valor → string canônica).
A mini-gramática Modifier do exemplo (stat:op:value, por exemplo 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;
}
}(Veja Assets/SheetForge.PluginDemo/ModifierCellParser.cs para a versão completa, de produção, com validação de op-token e sugestões de correspondência mais próxima.)
@target em tipos personalizados funciona apenas por registro: declare uma coluna como Modifier@Stats e o seu parser lê context.Type.TargetName ("Stats").
A checagem de integridade desse alvo — a aba existe? o id resolve? — pertence a um validador de domínio. Essa é a mesma divisão de trabalho de RecordId@Tab. Um nome de tipo não registrado com @ ainda é um erro com uma sugestão, então a segurança contra erros de digitação é preservada.
Nomes de tipo que o Core já possui. Os nomes de escalar embutidos — int, float, bool, string, Enum, RecordId, IntId, AssetRef, Color, AnimationCurve e Gradient — são registrados antes de qualquer plugin. Um parser que reutiliza um deles falha o registro com PluginRegistrationConflict — o embutido permanece, aquela chamada de RegisterCellParsers para no parser conflitante, e os outros slots do plugin ainda carregam — então um pacote que trouxer o seu próprio tipo Color ou Gradient precisa renomeá-lo (veja as notas de upgrade no changelog). Se o seu tipo armazena uma cor, uma curva ou um gradiente, você não precisa reimplementar a notação: os modelos de valor do Core ColorValue, CurveValue e GradientValue expõem TryParse(text, out value, out error) e Render(), CurveEvaluator / GradientEvaluator os amostram exatamente como a Unity faz, e um StudioCellEditorHint com o arquétipo ColorPicker, CurveEditor ou GradientEditor (§4.16) abre o editor nativo do seu tipo nos dois hosts.
Um tipo wrapper, do início ao fim (MyWrapper<T>)
Um wrapper é uma forma de valor genérica — Pair<int> = 1~2 — que empacota vários valores internos T em uma única célula. Você possui apenas a sintaxe externa (delimitador, aridade), e o Core faz o parsing do T interno recursivamente. Então Pair<RecordId@Effects>, Pair<Enum<DamageType>> e o aninhado Box<Pair<int>> simplesmente funcionam, e as referências internas são totalmente validadas.
Implemente ICellWrapperType e registre-o no mesmo hook RegisterCellParsers 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());Esse único registro te dá:
- Resolução recursiva de
@type. - Codegen fortemente tipado (
Pair<RecordRef> First;). - Bake.
- O round-trip de Exportação/Push.
- Pass-through de referência — um
RecordId@Tabdentro do wrapper é verificado quanto à integridade, propagado ao renomear uma chave e reescrito ao renomear uma aba.
As regras de rejeição e a ressalva do delimitador ; estão documentadas em Sintaxe da Planilha.
3. Validadores de domínio (opcional)
A validação do Core é fixa em quatro tipos (chaves, referências, @overlap, chaves de asset). Para regras entre colunas ("se type é Custom, script é obrigatório") ou regras entre abas (checando o significado de um registro referenciado), implemente 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'."));
}
}
}Validadores registrados se juntam automaticamente tanto à validação de importação quanto à validação prévia da autoria. Regras:
- Relate violações em
ctx.ErrorscomoImportErrorCode.DomainRuleViolation— nunca lance uma exceção. Uma exceção lançada é isolada e promovida; os outros validadores continuam executando. - Preencha os quatro elementos — onde (
CellCoordinate), o quê (ActualValue), por quê (Expected), como (Suggestion). O "como" é exibido ao pé da letra como a frase acionável. ctxte dá:- todas as tabelas parseadas (
Tables), - os índices de chave (
KeyIndices), - as chaves de asset (
AssetKeys—nullsignifica que a validação de asset foi ignorada).
- todas as tabelas parseadas (
- Coletar tudo e não montar nada parcial são herdados automaticamente.
4. Contribuidores de aresta (opcional)
Se você constrói ferramentas sobre o grafo de dados (ou quer que um futuro canvas de grafo veja as conexões do seu domínio), declare arestas que o scanner de referência do core não consegue ver — por exemplo, uma stat referenciada dentro de um valor de mini-gramática:
public sealed class SkillsPlugin : /* ... */, ISheetForgeEdgePlugin
{
public void RegisterEdgeContributors(EdgeContributorRegistry contributors)
{
contributors.Register(new ModifierStatEdgeContributor()); // effect → stat edges
}
}Um IEdgeContributor recebe um contexto entre abas somente leitura e adiciona itens EdgeSpec (aba de origem/destino + id de registro, campo opcional, registro de payload, rótulo). Contribuidores nunca emitem diagnósticos — arestas são material de projeção, não validação. Veja Kernel de Autoria.
4.4 Receita: um tipo personalizado que guarda uma chave dentro de si
RecordId@Tab é a única forma de referência que o Core entende, e ela ganha checagem de integridade, arestas de grafo, sugestões de correspondência mais próxima e propagação de renomeação de graça.
No momento em que a sua própria notação engole uma chave — attack:add:10, stat.hp>50, fire@0.4 — o Core enxerga apenas uma string opaca, então esses quatro serviços param na sua porta. Três registros devolvem três deles. Escreva-os como um conjunto: uma mini-sintaxe com apenas um dos três é a forma que produz "importa bem, mas nada aponta para nada".
| Peça | Contrato | O que ela restaura | Sem ela |
|---|---|---|---|
| 1. Integridade | IDomainValidator (§3) | Uma chave dentro da sua notação que não existe é relatada, com a coordenada e uma frase acionável | Um erro de digitação importa normalmente e falha em tempo de execução |
| 2. Visibilidade | IEdgeContributor (§4) | O vínculo enterrado se torna uma aresta real: o canvas o desenha, a lista Used by o conta, o índice de referências o indexa | A conexão existe nos dados e em lugar nenhum na tela |
| 3. O "como" | TextSuggestion.FindNearest dentro da peça 1 | "Unknown stat 'atack'. Did you mean 'attack'?" — a mesma forma de frase que os erros de referência embutidos usam | Um diagnóstico correto sem forma de agir sobre ele |
// 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."));
}
}
}Reutilize um único divisor para a notação. O parser, o validador e o contribuidor de aresta precisam concordar sobre onde uma chave começa e termina, e três cópias privadas dessa divisão é como eles se distanciam. (Um tipo wrapper, §2, ganha isso de graça: TrySplit é o divisor compartilhado.)
O quarto serviço — propagação de renomeação — precisa de mais uma coisa, e existem duas formas de consegui-la. Renomear um registro reescreve células que o referenciam apenas onde o Core consegue encontrar a chave no texto. Ele consegue fazer isso para um campo RecordId@Tab, uma lista deles, e um wrapper cujo TrySplit expõe a chave como um elemento. Ele não consegue adivinhar sozinho os limites de substring da sua gramática. Então você:
- diz a ele como — implementa
IReferencingCellType(§4.4a), o que substitui essa receita inteira de três peças por um único opt-in e restaura os quatro serviços de uma vez; - ou aceita o limite, que é ao menos honesto em vez de silencioso: a peça 1 relata a chave agora pendurada na próxima importação, com a coordenada e a sugestão.
A receita acima ainda é a resposta certa em um caso: quando a coluna não tem @target, porque não existe uma única aba onde a chave vive. O exemplo empacotado é exatamente isso — List<Modifier> não nomeia nenhum alvo, então o Core não consegue saber onde attack deveria resolver, e ModifierStatEdgeContributor abre essas arestas manualmente. Dê à coluna um alvo (List<Modifier@Stats>) e a §4.4a assume.
4.4a Dando à sua própria notação paridade de referência completa (opcional)
Implemente IReferencingCellType em um parser que você já registra, e uma coluna MyType@Tab deixa de ser um caso especial: ela é validada, sugerida, propagada, desenhada, selecionável e indexada exatamente como RecordId@Tab.
Não existe um novo canal de registro. O Core converte via cast os parsers já presentes no CellParserRegistry, da mesma forma que as capacidades de canvas são convertidas via cast a partir de contribuidores de aresta registrados (§4.12). Um tipo personalizado que não a implementa se comporta exatamente como se comportava antes, bit a bit.
Os cinco hooks
Eles trabalham todos sobre um elemento: a célula inteira para uma coluna escalar, ou um elemento separado por ; para List<MyType@Tab> — a mesma unidade que o ICellValueParser.TryParse do seu parser recebe.
| Hook | Responde | Usado para |
|---|---|---|
bool TryGetTokenKey(elementText, out key) | "Para o que este elemento aponta?" | Pertencimento — esta célula já está vinculada àquele registro |
string MakeToken(key) | "Escreva um novo vínculo para esta chave" | Uma célula vazia, ou anexar a uma lista. Preencha o payload com um ponto de partida neutro; uma superfície de autoria não deve inventar valores. Retorne null/vazio e o gesto fica desabilitado com um motivo, em vez de falsificado |
bool TryRetargetToken(elementText, newKey, out newText) | "Aponte isto para outra coisa" | Escolher um registro diferente na célula ▾, e reapontar um fio no canvas. Mude apenas o alvo — remover e refazer o token resetaria os números que uma pessoa digitou |
bool TryRemoveToken(elementText, key, out newText) | "Desvincule isto" | Retorne texto vazio e o elemento desaparece (a célula escalar limpa, o elemento de lista é descartado); retorne não vazio e essa parte permanece |
bool TryRewriteKeys(elementText, renames, out newText) | "Substitua todas estas chaves" | A varredura de renomeação. Separado de TryRetargetToken porque aquele é uma única instrução de uma pessoa, enquanto este é uma passagem em massa — e um elemento que carrega duas referências precisa reescrever ambas |
A metade de texto e a metade de valor
Adicione IRefBearingValue ao valor parseado também — as duas metades fazem trabalhos diferentes e ambas são necessárias. A metade de texto não consegue ver um valor parseado; a metade de valor não consegue restaurar a notação que o autor digitou:
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 };
}Implementar uma interface não adiciona campos, então o ScriptableObject cozido (bake) e o código gerado permanecem inalterados.
O que você ganha, a partir de um único opt-in — cada um destes é o próprio caminho de código do Core, não uma reimplementação:
- Integridade + sugestões — uma chave que não existe é relatada como
UnresolvedRecordId, com a coordenada e "você quis dizer …", compartilhando o orçamento de sugestão por campo com referências embutidas. - Propagação de renomeação com o payload intacto — renomear
attackparapowerreescreveattack:add:10empower:add:10; o operador e o número são do autor, e eles sobrevivem. - Grafo — o vínculo se torna uma aresta real com coordenadas: ele é desenhado, o nó ganha uma porta, a lista Used by o conta, e o índice de referências o tem nos dois sentidos.
- O seletor
▾— a célula ganha o mesmo dropdown pesquisável que uma célulaRecordId@Tabtem, e escolher um registro diferente substitui o alvo e mantém o resíduo. Sem o registro, o seletor recusa em vez de colar uma chave nua sobre o seu valor. - Detecção de órfãos e a regra de dropdown exportada — uma linha cujo único vínculo de saída vive dentro da sua notação não é mais tratada como desconectada, e uma coluna escalar do seu tipo ganha um dropdown de validação de dados sobre as chaves da aba de destino (Fontes, Exportação e Envio).
O uso mais simples é um tipo apelido (alias). Diga que o valor é apenas uma chave e o texto da célula é aquela chave:
TryGetTokenKeyapara espaços.MakeTokenretorna a chave.TryRetargetTokenretorna a nova chave.TryRemoveTokenretorna vazio.
A coluna é então um RecordId@Tab em todo aspecto funcional. A única coisa que resta a você é a apresentação: ela aparece sob o seu próprio nome no @type, e você pode anexar um widget de célula (§4.13) ou uma forma de canvas (§4.7) somente a essa coluna. Nenhum contrato separado é necessário para um alias.
Duas restrições, ambas estruturais:
- Nenhum
;no payload. O Core divide uma célula de lista em elementos antes do seu parser ou de qualquer um desses hooks ver o texto, então um ponto e vírgula dentro de um valor seria despedaçado em dois elementos. (Tipos wrapper carregam a mesma restrição, pelo mesmo motivo.) @targetprecisa nomear uma aba de planilha real, exatamente comoRecordId@Tabfaz — a aba virtual de um registro de código é rejeitada comUnknownTargetTab. Essa restrição é o que permite que o relato de referência não resolvida, as sugestões de correspondência mais próxima e a propagação de renomeação sejam as próprias do Core, sem modificação.
Nenhum dos cinco hooks pode lançar exceção: responda false ou null para qualquer coisa que você não consiga interpretar, e preserve o resíduo sempre que você reescrever.
Isso também funciona contra um espaço de chave inteira. Se a aba que o seu @target nomeia tem chave em IntId em vez de RecordId, nada muda no seu código — a chave que os seus hooks devolvem e recebem é simplesmente o inteiro escrito como texto. Qual espaço de chave comparar é decidido pela própria identidade da aba de destino, não pelo seu tipo.
- Validação, sugestões de correspondência mais próxima, propagação de renomeação, arestas, o seletor e a detecção de órfãos, tudo se acende da mesma forma.
- Uma gentileza que o Core adiciona para você ali: como um inteiro pode ser grafado de várias formas, uma renomeação entrega ao
TryRewriteKeysa grafia como ela aparece naquele elemento, ao lado da canônica (007e7ambos mapeiam para12), então uma busca ordinal dentro do seu tipo não perde um valor preenchido com zeros. - A demonstração empacotada não inclui um tipo personalizado com referência mirando uma aba
IntId— o exemploModifiermira uma com chave em string — então esse caminho tem testes, mas nenhum exemplo trabalhado para copiar.
4.5 Marcadores estruturais personalizados (opcional)
Os marcadores embutidos são @name, @type, @desc, e três opcionais:
@overlap.@style, que descreve a planilha — seu rótulo de grupo e cor — em vez das suas colunas.@enum, que marca a planilha como um conjunto de definições de enum, em vez de uma tabela.
@overlap é um marcador por coluna: sua linha carrega um valor por coluna, validado coluna por coluna. Você pode registrar seus próprios marcadores da mesma forma — por exemplo, um marcador @curve que registra como cada coluna numérica interpola. Implemente IStructuralMarkerDefinition e registre-o 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");
}
}A planilha então aceita uma linha @curve (em qualquer ordem, acima dos dados):
@name | level | atk
@type | int | int
@curve | | ease
| 1 | 10- O valor é armazenado como metadado agnóstico de domínio:
field.MarkerValues["curve"]. Um validador de domínio ou contribuidor de aresta o lê a partir decontext.Tables[tab].Schema.Fields[i].MarkerValues; a janela de autoria o mostra no tooltip do cabeçalho da coluna. - Uma célula rejeitada se torna um diagnóstico
MarkerCellInvalid— você fornece o "por quê" e o "como corrigir"; o Core fornece a coordenada e o valor problemático. - Nomes de marcador precisam ser identificadores válidos e não podem colidir com os seis embutidos (
@name/@type/@desc/@overlap/@style/@enum—Registerlança uma exceção caso contrário, exposta comoPluginRegistrationConflict). - Marcadores são para metadado por coluna, não novas formas de dado — um marcador possui sua validação de célula, não a linha inteira. Linhas de marcador personalizado são preservadas ao pé da letra na exportação/round-trip e se movem com sua coluna em toda edição de estrutura (adicionar / excluir / mover / renomear).
- O codegen não faz o bake de valores de marcador (assim como
@overlap, eles são apenas metadado de validação/exibição, invisível ao fingerprint do esquema).
4.6 Templates de "Criar planilha" (opcional)
O fluxo Create sheet vem com dois templates embutidos — uma planilha de itens usando apenas tipos do core, e uma planilha de definições @enum — mais "do zero".
Templates de domínio são esqueletos de planilha que usam os seus enums, tipos personalizados e referências. Eles vêm de plugins, então um template está presente exatamente quando o seu plugin está. Implemente 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", /* ... */ ""),
}));
}
}- Um template carrega uma ou mais abas, cada uma um TSV normalizado completo: linhas de comentário/marcador mais dados de exemplo. Isso é diferente do exemplo embutido de item, que é um esqueleto de 0 linhas. Como os tipos do seu domínio já estão registrados (o plugin está carregado), as planilhas criadas são reimportadas com sucesso imediatamente.
- As strings de exibição são suas. Um plugin possui seu próprio texto (o pacote de exemplo fica fora da proteção de palavras de domínio) — você não está restrito às chaves
Locdo Core. - Templates com múltiplas abas criam todas as suas abas e reimportam uma vez, então referências entre abas se resolvem juntas. O painel Criar oculta o campo de nome de aba para esses casos (os nomes de aba são fixados pelo template).
- Chaves, nomes de exibição vazios, zero abas e TSV de aba vazio são rejeitados (
Registerlança uma exceção, exposta comoPluginRegistrationConflict).
4.7 Sobreposições de canvas por aba (opcional)
O canvas do Data Studio decide o que desenhar por conta própria. Você abre um registro — o terminus — e ele percorre o índice de referências para fora, coletando tudo que aquele registro consome, e então organiza o resultado da esquerda para a direita. Isso funciona sem nenhum plugin.
O que um plugin adiciona é o que o core não consegue ver ou não consegue saber:
- uma identidade que não é um registro de planilha,
- um vínculo que não está escrito em uma coluna
RecordId@Tab, - uma ordem que é regra de domínio, e não profundidade de referência.
Implemente IRecordCanvasAugmenter e registre-o por aba 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");
}
}- O registro é por nome de aba. Abas que você não registra ainda ganham um canvas — o fechamento do core — então um plugin nunca precisa cobrir toda planilha. Uma aba duplicada, um nome de aba vazio e uma sobreposição null são rejeitados (
Registerlança uma exceção, exposta comoPluginRegistrationConflict). - Você adiciona, você não substitui. Quais registros aparecem é a resposta do fechamento. Um nó virtual cuja (aba, chave) já está em tela é descartado — o registro real vence — então uma sobreposição não pode inventar um registro que existe em uma planilha. O que ela pode fazer é trazer identidades que não têm linha de planilha nenhuma.
- Nomes são a única exceção. Uma dica de exibição é apresentação, e não identidade, então ela sim se aplica a registros que já existem. Ela também pode nomear registros que não estão em tela de forma alguma: o seletor de conexão lê esses, e é por isso que o subtítulo de um card e uma linha do seletor dizem a mesma coisa. Nomes em branco são ignorados (isso equivale a "usar o padrão"), e o primeiro nome para um registro vence.
- Uma aresta traz o seu próprio nó. Se uma ponta de uma aresta extra não está em tela, ela é adicionada como um nó, então o vínculo nunca fica pendurado. Uma aresta com uma chave vazia em qualquer ponta é ignorada.
- Onde a célula está, e para onde a seta aponta, podem diferir. Por padrão, presume-se que a célula nomeada por
fieldNamefica no registro de partida. PassefieldOnTarget: truequando ela fica na chegada em vez disso — um fio de evento publicado é desenhado evento → registro, mas o texto está na própria coluna do registro. O inspector de fio então aponta para a célula real, em vez de para nada. - Ciclos: o core marca os que ele consegue ver, você declara os que você conhece. Se suas arestas extras fecham um loop, o canvas classifica a aresta de retorno e a desenha tracejada por conta própria. Julgar se um ciclo é um problema é trabalho de um validador de domínio (§3); o canvas é material de exibição, nunca validação.
isCyclicmarca um fio como um ciclo para exibição, sem tocar no layout.cyclicNotecarrega o que só você sabe (um valor de amortecimento, digamos) — mantenha o rótulo como o nome da coluna e coloque a explicação na nota.
- Dicas de camada vêm em dois sabores.
SetLayeré absoluta — a coluna 0 é a mais à esquerda e negativos vão ainda mais à esquerda.SetLayerRelativeconta a partir do terminus (−1 é a coluna imediatamente à esquerda dele), que normalmente é o que um estágio fixo significa. A imagem então se lê da mesma forma, seja a cadeia rasa ou profunda, e você não precisa fixar o próprio terminus para impedir que os estágios colidam.- Dicas relativas resolvem contra a coluna do terminus antes de qualquer dica movê-la, então a ordem em que você adiciona as dicas não pode mudar o resultado. Se o resultado for para a esquerda de zero, a imagem inteira se desloca para a direita.
- Uma dica para um nó que não está em tela é descartada, e a primeira dica para um nó vence.
- Falhas são contidas.
Augmentroda dentro de try/catch: uma exceção se torna um aviso de console em inglês, e a imagem do core, nunca uma janela quebrada. - Ampliar nunca quebra você. Toda capacidade adicionada desde o primeiro lançamento é um argumento à direita (trailing) ou um novo método; uma sobreposição escrita contra a superfície anterior compila e se comporta de forma idêntica.
(Veja Assets/SheetForge.PluginDemo/Graphing/ExampleReactiveAugmenter.cs e ExamplePipelineAugmenter.cs para as sobreposições completas — uma reação que faz crescer nós de evento e um bloco de código em torno do registro, e um elenco (cast) cujos estágios fixos são fixados nas suas próprias colunas.)
4.8 Registros de código — alvos de referência que vivem no código (opcional)
Alguns alvos de referência não são criados em uma planilha de forma alguma: os átomos de execução para os quais o seu runtime despacha. Registrá-los como uma aba virtual travada os coloca na superfície de autoria como somente leitura, e impede que arestas apontando para eles sejam desenhadas como quebradas. Implemente 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),
}));
}
}- Três pontos de consumo:
- a barra lateral do Data Studio mostra a aba virtual sob READ-ONLY como uma grade de chave/rótulo/raises;
- uma sobreposição de canvas pode consultar as entradas através de
context.CodeRegistries; - e o inspector de nó lista o
Raisesde uma entrada.
- As chaves entram na checagem de existência do Studio. Uma aresta cujo alvo é uma chave registrada — tipicamente uma declarada por um
IEdgeContributor(§4) ou construída pela sua forma — não é pintada como uma referência quebrada. - O validador de importação não conhece abas virtuais. Registros de código são um conceito de superfície de autoria, então não tipe uma coluna de planilha como
RecordId@_Refs(a importação relatariaUnknownTargetTab). Conecte dados de planilha a átomos de código da forma como o exemplo faz — uma colunatypemais uma consulta de contribuidor de aresta / forma. - Escolha um nome que não possa colidir com uma planilha real (o exemplo usa o prefixo
_). Se um colidir, o Studio sinaliza o conflito na barra lateral, em vez de esconder silenciosamente qualquer um dos dois. - Rejeições: origem
null, um nome de aba vazio, ou um nome de aba duplicado lança uma exceção (exposta comoPluginRegistrationConflict); uma listaRaisesnullé normalizada para vazia. O Core trata chave / rótulo / raises como strings opacas — ele nunca as interpreta.
(Veja Assets/SheetForge.PluginDemo/Graphing/ExampleCodeAtoms.cs.)
4.9 Widgets de grafo do Data Studio (opcional, assembly Editor)
Um widget é uma faixa da sua própria UI acima do canvas de grafo — uma visão geral de estágio fixo, um selo agregado, o que quer que o domínio queira. O core não distribui nenhum widget, então essa área fica vazia até um plugin preenchê-la.
Como o tipo de retorno é um VisualElement, esse contrato vive no assembly Editor — a mesma assimetria justificada de ISheetSourceProvider. Implemente-o em um assembly do lado do Editor que referencia SheetForge.Editor e 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
}
}- A descoberta é automática — o
TypeCacheencontra toda implementação com um construtor sem parâmetros; não há chamada de registro nem registry para vincular. Uma falha de instanciação é registrada em log e pulada. - Somente leitura por contrato. O contexto expõe as tabelas parseadas, o índice de referências e os registros de código — mas nenhuma superfície de preparação. Autoria a partir do grafo pertence a uma ação de inspector (§4.10), que a media.
- Nenhum estado dentro do elemento. Widgets são recriados a cada reconstrução do grafo; mantenha o estado nos seus próprios objetos. Reconstruções são coalescidas na frequência de ação humana, não de teclas.
- Exceções são isoladas —
AppliesTo/Createlançando exceção produz um aviso de console em inglês; o grafo continua sendo desenhado.
(Veja Assets/SheetForge.PluginDemo/Demo/Editor/ExampleStageStripWidget.cs.)
4.10 Ações de inspector do Data Studio (opcional, assembly Editor)
Uma ação é um botão extra no inspector de nó — "o que este domínio pode fazer com este registro". O core fornece uma ação embutida (Go to this sheet); tudo o mais chega através deste contrato:
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();
}
}- A sessão de autoria deliberadamente não é exposta. Toda alteração de preparação precisa ser um único passo de Undo nativo com a geração da projeção incrementada; entregar a sessão bruta institucionalizaria uma forma de contornar essa regra.
StageCell(tab, recordId, field, rawText)eStageCells(writes)são toda a superfície de mutação, e a janela é a dona da contabilidade. - Mudando várias células? Use
StageCells.context.StageCells(new[] { new EdgeCellWrite(tab, recordId, field, text), … })prepara a lista inteira como um único passo de Undo, tudo ou nada: se uma gravação não puder ser aplicada, nenhuma é. ChamarStageCellvárias vezes divide o Ctrl+Z naquele número de passos, e, para colunas paralelas, isso significa que um estado meio-válido aparece no meio do desfazer. Uma lista null ou vazia não faz nada. - Passe texto canônico. O texto preparado é parseado pelo mesmo parser que o importador usa, no momento do reflect — então escreva o que a planilha conteria.
- Uma chave que não está no baseline é um no-op (um registro totalmente novo ou não resolvido): nada é gravado silenciosamente.
- Serviços:
FocusCellrola a grade até uma coordenada,RequestRebuildpede uma atualização depois que você prepara algo. - Descoberta, rótulos e isolamento funcionam exatamente como os widgets: descoberta via
TypeCache, fallback ao pé da letra para umLabelKeynão registrado (uma chave vazia recorre ao nome do tipo), e try/catch em torno deAppliesTo/Execute.
(Veja Assets/SheetForge.PluginDemo/Demo/Editor/ExampleInspectorAction.cs. O seu assembly Editor — SheetForge.PluginDemo.Demo.Editor — referencia SheetForge.Editor, SheetForge.Core e o assembly do plugin; isso é toda a conexão que uma extensão do lado do Editor precisa.)
4.11 Predefinições de cor (opcional)
O SheetForge pinta as suas próprias janelas a partir de um pequeno vocabulário de slots de cor (superfícies, linhas, texto, cores semânticas, marcas de preparação). Uma predefinição recolore os slots que lhe interessam; todo outro slot mantém o padrão do produto. Implemente 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 },
}));
}
}- Cores são
0xRRGGBB. O Core não referencia nenhum tipo de engine, então não há nenhumUnityEngine.Coloraqui; o byte mais alto é ignorado. Superfícies translúcidas (preenchimentos de selo, o scrim do modal) são derivadas de uma cor de slot mais um alfa fixo — você define a cor, não o alfa. - Forneça as duas telas. Forneça um mapa escuro e um mapa claro; a escolha de brilho do usuário (seguir o editor / sempre escuro / sempre claro) escolhe um. Slots que você deixar de fora recorrem ao padrão do produto para aquele brilho, então uma predefinição de três slots é perfeitamente normal.
- Registrar não a aplica. A sua predefinição aparece em
Preferences ▸ SheetForge ▸ Theme ▸ Colour presetao lado das embutidas Default e High contrast; só a escolha do usuário tem efeito. As strings de exibição são suas (nenhuma chaveLocdo Core é necessária). - Ids em branco, duplicatas, e os ids embutidos reservados (
default,highContrast) são rejeitados (Registerlança uma exceção, exposta comoPluginRegistrationConflict). - O que um tema não consegue reestilizar: os widgets nativos da Unity desenhados dentro das nossas janelas (chrome de botão, bordas de campo) continuam seguindo a skin do editor — veja Capacidades e Limites.
4.12 Editando no canvas de grafo (opcional)
O grafo do Data Studio é uma superfície de autoria, não uma imagem: clicar com o botão direito cria registros, os conecta e desconecta fios (veja Data Studio). Tudo isso funciona em um projeto simples para colunas RecordId@Tab comuns.
As capacidades abaixo o estendem onde o core não consegue alcançar. Nenhuma delas muda um contrato existente, então um plugin que as ignora compila sem alteração.
Como as capacidades são descobertas (leia isto primeiro)
Uma capacidade nunca é descoberta por conta própria. A janela encontra cada uma delas fazendo cast dos objetos que já estão registrados:
| Capacidade | Cast a partir de | O que ela adiciona |
|---|---|---|
IAuthorableGraphShape | a sobreposição de canvas registrada por ISheetForgeGraphPlugin | Onde novos registros podem ser criados |
IAuthorableEdgeContributor | o contribuidor de aresta registrado por ISheetForgeEdgePlugin | Transformar um gesto em uma gravação de célula |
IBatchAuthorableEdgeContributor | o mesmo contribuidor de aresta | Transformar um gesto em várias gravações de célula |
IVirtualNodeFactory | o mesmo contribuidor de aresta | Oferecer "criar mais um" no menu do nó |
IEdgeSlotDeclarer | o mesmo contribuidor de aresta | Declarar slots de conexão que o esquema não consegue derivar |
IEdgeTokenEditor | o mesmo contribuidor de aresta | Descrever um token e editar a parte que não é a chave |
Então as cinco capacidades do lado da aresta só são alcançadas se a classe estiver registrada como um IEdgeContributor (via ISheetForgeEdgePlugin, §4). Se o seu domínio não abre nenhuma aresta própria, isso não é motivo para pular o registro — implemente ContributeEdges como um método vazio e registre-o de qualquer forma. Esse contribuidor vazio é a forma oficialmente suportada de entrar:
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) => …;
}Todas elas rodam dentro de try/catch: uma exceção se torna um aviso de console em inglês e desabilita apenas aquela affordance, nada mais.
Onde novos registros podem ser criados — IAuthorableGraphShape
Existem dois padrões, e eles são deliberadamente diferentes.
- A lista de abas criáveis — o eixo que esta capacidade substitui, que também decide se um canvas sequer abre — cobre toda aba que o esquema da aba em foco consegue alcançar, seguindo referências transitivamente. Ela é computada a partir do esquema, não dos dados, então ela se sustenta mesmo em uma planilha que ainda não tem linhas. Alcançar uma aba a dois vínculos de distância é passo a passo: crie o registro intermediário, as suas portas aparecem, e o próximo salto entra na cascata.
- A cascata de vínculo — o seletor que você de fato vê em um canvas vazio — é mais estreita. Ela começa a partir das abas para onde as portas atualmente desenhadas apontam.
De qualquer forma, abas pertencentes a um registro de código e abas sem coluna-chave são descartadas, porque um novo registro ali não poderia ter uma identidade.
Uma sobreposição registrada para aquela aba (§4.7) pode adicionar esta interface para substituir os dois padrões. Uma aba que ela nomeia e que nenhuma porta em tela aceita permanece listada na cascata de vínculo com o seu motivo anexado, em vez de desaparecer:
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" };
}Tornando a sua própria aresta editável — IAuthorableEdgeContributor
Uma aresta que você abriu com IEdgeContributor (§4) é desenhada, mas não editável, porque só você conhece a notação em que ela vive. Adicione esta interface para transformar um gesto de volta em texto de célula; a janela prepara exatamente o que você retorna, e o parser continua sendo o juiz final:
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;
}
}falsesignifica que nada acontece. Nenhuma preparação é criada, e o item de menu fica desabilitado com um motivo honesto — nunca uma edição meio-aplicada. Retornartruecom texto sem sentido é permitido, mas inútil: o valor preparado passa pela mesma validação de pre-flight que um digitado, e aparece em Problems.- Endereçe por chave, não por linha.
EdgeCellWritenomeia (aba, id do registro, campo); números de linha são reresolvidos no momento da gravação, então um plano preparado sobrevive a linhas se movendo. - Você é chamado durante um gesto. Os dois métodos rodam dentro de try/catch — uma exceção se torna um aviso de console em inglês e desabilita apenas aquela affordance, nada mais.
- Pergunte ao contexto, não à planilha.
CellTextretorna o valor incluindo a preparação, então dois vínculos feitos em sequência se enxergam um ao outro. Ler a tabela parseada em vez disso perderia o primeiro.
Mudando várias células em um gesto — IBatchAuthorableEdgeContributor
Alguns dados mantêm um item espalhado por colunas paralelas: stepDelays | stepTargets | stepCounts, onde o índice i de cada coluna é um passo. Adicionar um vínculo ali precisa fazer crescer toda coluna de uma vez, ou as colunas acabam com comprimentos diferentes — um estado meio-válido que um plano de célula única não consegue evitar.
Esta capacidade é a irmã de IAuthorableEdgeContributor (não uma subclasse), então contribuidores que só têm a forma singular ficam intocados:
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) => …;
}- Tudo ou nada. Toda gravação da lista é preparada como um único passo de Undo nativo; se uma sequer não puder ser gravada (nenhuma linha assim, origem somente leitura, pipeline em execução), nada é preparado.
- O lote vence. Se uma classe implementa tanto a forma singular quanto a forma de lote, a janela pergunta apenas à forma de lote — um gesto nunca tem duas respostas diferentes. Contribuidores ainda são perguntados na ordem de registro, e o primeiro que planeja vence.
- Toda gravação precisa de um endereço. Uma lista contendo uma gravação com uma aba ou campo vazio (ou uma lista vazia) conta como "nenhum plano".
- Desvincular roda em cadeia. Quando vários fios em um card são cortados em um único gesto, o contexto que você lê já carrega os planos anteriores deste gesto, então cortar dois tokens da mesma célula remove ambos. O contrato singular não tem superfície para receber esse valor intermediário — esta capacidade é como esse limite é removido.
- Um registro sendo criado não pode ser um alvo. No fluxo "criar e vincular em um gesto", endereços de gravação são resolvidos antes de a nova linha entrar na sessão, então um plano visando o registro sendo criado não pode se sustentar, e o gesto inteiro falha honestamente. Visar linhas que já existem (o caso de coluna paralela) não é afetado.
Criando mais um de algo — IVirtualNodeFactory
Quando "mais um" não é uma nova linha, mas mais um elemento em cada uma de várias células, o canvas não consegue inventar o gesto. Declare os tipos que você consegue criar e devolva as gravações de célula quando um for escolhido:
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;
}
}- O rótulo já está traduzido. O Core não o traduz — forneça a string que o seu pacote resolveu (veja §4.14). Uma
/no rótulo cria um submenu, então você pode agrupar as suas próprias entradas. tabpode ser um nome de aba virtual, ou vazio. Nós que a sua sobreposição de canvas coloca em tela não vivem em uma planilha; o menu ainda oferece o que você declara, porque as células que você grava são nomeadas pelo seu plano, não pela identidade do nó. Abas pertencentes a um registro de código são excluídas.- Um passo de Undo, tudo ou nada — a mesma regra da capacidade de lote acima.
falsenão prepara nada.
Declarando slots de conexão — IEdgeSlotDeclarer
Slots de conexão normalmente vêm do esquema (colunas RecordId@Tab). Um nó que a sua sobreposição colocou em tela não tem colunas, e uma aresta de contribuidor só revela um slot uma vez que um vínculo já existe — então o primeiro vínculo não tinha onde começar. Declare os slots em vez disso:
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;
}- O nome tem dois deveres. Ele precisa ser único dentro daquele nó, e precisa ser igual ao
FieldNameda aresta que você desenha nele — a busca de slot e a ancoragem de fio combinam ambos com esse nome. Se uma coluna de planilha já tem esse nome, a planilha vence e a sua declaração é silenciosamente descartada. - Declarar não é planejar. Um slot declarado é conectado através do seu plano (
IAuthorableEdgeContributorou a forma de lote). Declarar sem planejar abre a porta, mas nada é preparado — implemente ambos. - Portas abrem em nós sem linha de planilha. Para um nó cuja aba não é uma planilha, a janela não procura uma linha por esse nome; o endereço de gravação vem do seu plano e é checado no momento da preparação.
Editando o que o token diz — IEdgeTokenEditor
Vincular e desvincular movem um token inteiro. Frequentemente o token é mais do que uma chave: attack:add:10 nomeia uma stat e o quanto. Adicione esta capacidade ao mesmo contribuidor, e o inspector de fio ganha uma linha para esse resto — a parte que não é a chave:
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…
}
}- As duas metades leem a mesma célula. Uma aresta sabe para onde aponta, não com quais letras está escrita hoje, então descrever recebe o mesmo
EdgeAuthoringContextque a gravação recebe. É isso que torna o fragmento destacado e o fragmento reescrito comprovadamente o mesmo. - A chave nunca passa por essa porta. Mudar para onde um vínculo aponta é reapontar (arrastar o fio); esta linha só muda o resto. Retornar
falsede qualquer uma das metades esconde ou honestamente desabilita a linha — sem preparação, sem falha silenciosa. - O widget é seu para descrever.
isChoicecom opções desenha um popup, senão um campo de texto; o rótulo da linha e os rótulos das opções são suas strings. Se não houver nenhum resto, construanew EdgeTokenDescription(tokenText)e a linha não é desenhada — uma referência do core (cuja chave é o token inteiro) se comporta assim sem nenhum código.
Tornando os seus próprios fios editáveis de forma alguma
Um fio só pode ser editado se ele disser em qual célula está escrito. O core preenche isso para as referências que ele mesmo lê; uma aresta extra que você adiciona (§4.7) faz isso nomeando o campo:
// 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");Nomear uma célula não promete que ela é editável — apenas diz onde o vínculo vive. Uma aresta que você adicionou é entregue ao mesmo encanamento que uma aresta de contribuidor usa, então ela se torna editável exatamente quando um IAuthorableEdgeContributor a reivindica. Se essa coluna é uma coluna de texto simples ou de enum sem ninguém para reescrevê-la, o canvas relata o fio como não editável aqui, o que é a verdade, em vez de um no-op silencioso.
4.13 Widgets de célula personalizados (opcional, assembly Editor)
A grade desenha toda célula com um widget embutido (toggle booleano, popup de enum, seletor de referência, texto bruto). Quando um tipo merece uma entrada melhor — uma curva, uma cor, um compositor de mini-gramática, uma caixa multilinha — substitua o widget para aquele nome de tipo, sem tocar em como o valor é parseado:
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;
}
}- O widget molda a entrada, o parser é o dono do significado. Seja o que for que você confirma é texto canônico de planilha; ele passa pela mesma validação de pre-flight que um valor digitado, e problemas aparecem no painel Problems. O widget nunca precisa validar.
- Duas superfícies de confirmação, de propósito.
Commit(escolher de uma lista, soltar um slider, perder o foco) cria um passo de Undo;CommitTyping(por tecla) coalesce uma sequência em um único passo. Colapsar os dois em uma única chamada ou espalharia passos de Undo por letra, ou fundiria duas escolhas distintas. - Retornar
nullrecusa a célula, e o widget embutido assume — a resposta honesta para formas que você não trata (List<T>do seu tipo, campos opcionais).context.Type(o token@typeparseado) carrega tudo que é necessário para decidir. ReferenceKeys(tab)entrega a você a mesma lista de candidatos que o seletor de referência embutido usa (chaves projetadas ∪ chaves de registro de código ∪ chaves de nova linha preparada, ordenadas) — sem necessidade de reunir a sua própria. Para deixar a pessoa escolher daquela lista no mesmo dropdown que a célula embutida abre, chameStudioKeyPicker.Show(screenAnchor, tab, candidates, picked)e insira a chave retornada dentro da sua própria notação antes de confirmar. (Criar um registro, deixar a célula vazia e alternar múltiplos itens de uma lista são regras próprias da célula de referência embutida, e não estão nessa fachada — um widget que possui o texto de célula inteiro possui essas decisões também.)- Você pode reivindicar um nome de tipo embutido, não apenas o seu próprio. O ramo de widget registrado roda primeiro, então
TypeName => "float"realmente substitui a caixa de texto bruto para toda colunafloat— é assim que um slider, um campo de porcentagem ou uma caixa com sufixo de unidade entram. Duas ressalvas vêm com isso:- Aplica-se a toda coluna daquele tipo no projeto, então limite o escopo lendo
context.FieldName/context.Tabe retornandonullpara as colunas que você não quis dizer. - O que você confirma continua sendo texto canônico de planilha, então um slider precisa renderizar o seu valor da forma como o parser o lê de volta (veja
CanonicalValueRenderer.RenderFloatpara a grafia de float que o round trip espera).
- Aplica-se a toda coluna daquele tipo no projeto, então limite o escopo lendo
- Conflitos avisam, a descoberta é automática. A mesma descoberta via
TypeCacheque todo outro contrato; se dois provedores reivindicam um nome de tipo, o primeiro encontrado vence, e um aviso de console nomeia os dois. UmCreateEditorque lança exceção é capturado, avisado, e a célula recorre ao widget embutido. - Antes de escrever um, veja se um hint resolveria. Se tudo que você quer é um dropdown, uma caixa multilinha, um slider, um toggle, um seletor de cor, um editor de curva ou um editor de gradiente, registre um
StudioCellEditorHintem vez disso (§4.16) — sem código de widget, e funciona no navegador também. A ordem é: este contrato primeiro, depois o hint, depois os padrões do core; então um hint é o que a célula recebe sempre que nenhum widget reivindicou o tipo, ou o que reivindicou recusou.
4.14 Strings de UI de plugin (opcional)
Rótulos que o seu pacote mostra — ações de inspector, legendas de widget, as superfícies declarativas do §4.16 — podem seguir o idioma do usuário. Registre frases por chave de idioma; Loc.Tr consulta essa sobreposição antes das tabelas do produto, e o t() do navegador faz o mesmo:
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", "…");
}
}- Este contrato vive no Core, então coloque-o no seu assembly principal. Os dois hosts mostram os rótulos do seu pacote, e o navegador só carrega a DLL principal — um plugin de strings sentado no assembly complementar de editor deixaria o app web mostrando chaves brutas.
- Registrar é opcional. Uma chave não registrada continua sendo exibida ao pé da letra — este contrato é um caminho de upgrade, não uma exigência.
- Idiomas são códigos IETF (
"en","ko","zh-Hans","pt-BR", …), comparados sem diferenciar maiúsculas/minúsculas.- Registre inglês no mínimo: a busca recorre a idioma pedido → inglês → nada encontrado, então um usuário em qualquer outro idioma lê a sua frase em inglês, em vez da chave bruta.
- Um código que o produto não conhece é rejeitado com um motivo, em vez de ser dobrado em inglês — um erro de digitação que silenciosamente virasse inglês seria impossível de rastrear.
- Chaves do produto não podem ser sobrescritas — um registro que nomeia uma chave embutida é recusado, então uma sobreposição nunca pode fazer a UI discordar das próprias frases do produto. Rótulos de menu, em particular, são cozidos (bake) diretamente das tabelas de idioma, então uma sobreposição que pudesse reescrevê-los faria o texto de orientação e o caminho de menu real discordarem. A sobreposição é para chaves novas.
- Registros duplicados entre pacotes mantêm o primeiro encontrado, com um motivo registrado — se o último registro vencesse silenciosamente, a tela dependeria da ordem de instalação dos plugins.
- Chaves vazias e valores vazios também são recusados. Toda recusa é uma linha em inglês voltada ao desenvolvedor, porque o público é o autor do plugin, não o usuário final.
- A regra de paridade de 10 idiomas do produto permanece intocada: as suas strings vivem em uma sobreposição de consulta ao lado das tabelas do core, nunca dentro delas.
(A demonstração distribui isso em Assets/SheetForge.PluginDemo/ExampleLocStrings.cs — no assembly principal, pelo motivo acima — registrando os rótulos que a sua ação de inspector (§4.10) e as suas superfícies declarativas (§4.16) exibem.)
4.15 Texto multilinha em uma célula (diálogo, descrições, scripts)
Uma quebra de linha real nunca pode viver dentro de uma célula. A entrada do pipeline é TSV, onde uma tabulação separa células e uma quebra de linha separa linhas, então uma célula que guarda qualquer um dos dois caracteres não tem representação alguma.
Toda origem impõe isso na porta, em vez de deixar uma grade corrompida passar:
- os leitores de CSV e xlsx relatam
UnsupportedCellCharactercom a coordenada da célula, coletando toda célula problemática, não apenas a primeira; - a busca do Google faz o mesmo;
- e em um arquivo
.tsvo caractere já era o separador de linha.
Isso é uma constante de design do formato, não uma lacuna esperando ser fechada. Então um domínio com texto longo trabalha com isso, através de uma convenção de três partes inteiramente dentro do território do plugin.
1. Escolha um escape e o escreva no seu parser. A escolha convencional é um \n literal de dois caracteres na planilha, sem escape na entrada e com escape novamente na saída:
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;
}
}Faça as duas direções serem inversos exatos, e prove isso. TryRender é o que a Exportação e o Push gravam de volta, então se ele não desfizer o TryParse caractere por caractere, um round trip "planilha → importação → exportação → planilha" reescreve texto que ninguém editou. Normalizar \r\n para \n na saída (como acima) é o que impede um valor criado no Windows de alternar entre duas grafias em exportações sucessivas. Um único teste que renderiza um valor parseado e o compara ao texto original da célula é suficiente para travá-lo.
2. Dê à célula um editor de verdade. Um valor com escape \n é desagradável de digitar em uma caixa de uma linha, que é exatamente para o que a §4.13 serve — registre um IStudioCellEditorProvider para "Prose" que retorna um TextField multilinha (multiline = true), mostrando o valor com quebras de linha reais e confirmando-o com o escape reaplicado. Confirme ao perder o foco com Commit (um passo de undo por sessão de edição), em vez de por tecla.
3. Conheça o único lugar em que a convenção não alcança. Alguém digitando Alt+Enter diretamente na Planilha do Google cria uma quebra de linha genuína na célula ao vivo, e essa célula é recusada na próxima busca, com uma coordenada apontando para ela. A recusa é honesta e corrigível, mas é uma recusa — então, se os redatores da sua equipe criam prosa diretamente na planilha, diga na sua própria documentação que texto longo é escrito com \n, ou deixe-os criá-lo no widget de célula do Data Studio do passo 2, onde o escape acontece por eles.
4.16 Superfícies de autoria declarativas (opcional)
Os §4.9, §4.10 e §4.13 devolvem um VisualElement, e é exatamente por isso que eles são exclusivos do editor: o navegador não consegue carregar um tipo UIToolkit, então uma extensão escrita dessa forma existe em uma tela e não na outra.
Este contrato responde às mesmas necessidades como dado. Você descreve o invólucro — um id, uma chave de rótulo, um posicionamento, um tom — e fornece apenas o predicado e o efeito como delegates. Um único registro é então desenhado tanto pelo renderizador UIToolkit do editor quanto pelo renderizador React do navegador.
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));
}
}O vocabulário é deliberadamente limitado — ele cresce apenas por adição, nunca por inserção, então um registro existente mantém o seu significado.
- Cinco posicionamentos para uma ação:
Inspector,RowContextMenu,TopbarMenu,ColumnHeaderMenu,CanvasNodeMenu.- Cada um preenche o contexto com o que aquele assento conhece — o posicionamento de linha carrega o registro, o posicionamento de coluna carrega o nome da coluna, o posicionamento de canvas carrega o registro do nó — e deixa o resto vazio, então proteja-se antes de ler um campo que um assento não fornece.
- Treze tipos de nó para um painel ou selo:
Row,Label,Chip,Badge,Button,Rule,Heading,KeyValue,Table,List,Progress,Input,Link.- Eles são construídos através de fábricas estáticas (
StudioUiNode.Label(…),.WithTooltip(…)), então um nó é imutável e apenas os campos que fazem sentido para o seu tipo são definidos.
- Eles são construídos através de fábricas estáticas (
- Sete arquétipos de editor de célula:
Dropdown(você fornece os candidatos),MultilineText,Slider(você fornece o intervalo),Toggle(você fornece os dois textos canônicos),ColorPicker(#RRGGBB/#RRGGBBAA),CurveEditoreGradientEditor(o texto da célula é a notação canônica de curva / gradiente de Sintaxe da Planilha — um pacote cujo próprio tipo escreve essa notação, por exemplo através deCurveValue.Render(), pode declará-los). Os tipos embutidosColor,AnimationCurveeGradientsão conectados através do mesmíssimo mecanismo —BuiltinCellEditorHintsguarda os três hints deles — e um host consulta os registros de um pacote primeiro, então registrar um hint sob um desses nomes de tipo sobrepõe a escolha embutida, em vez de ser recusado. No editor, os últimos três arquétipos são os campos de cor, curva e gradiente da Unity; no navegador, são os próprios editores do aplicativo; umList<>de um tipo que carrega um deles vira um editor de chips em ambos. O tipoFalloffdo Plugin Demo faz exatamente isso: o seu parser lê a célula comCurveValue.TryParse, e um único registro de hint dá a ele um campo de curva na Unity e o editor de curva no navegador. - Nenhum número de layout em lugar nenhum. Pixels e proporções vazariam a granularidade de uma tela para a outra; você diz o quê mostrar, e cada renderizador decide como posicioná-lo.
Regras que valem a pena conhecer antes de escrever uma:
- A mutação passa pela mesma porta que a sua mão passa.
StudioSurfaceContextdá a uma ação exatamente quatro poderes —StageCell,StageCells(várias células, um passo de Undo, tudo ou nada),FocusRecord,RequestRebuild— além dos somente leituraTables/References/CodeRegistries.- Então o verbo de um plugin é uma edição preparada comum: um passo de
Ctrl+Z, nada chega à planilha até você fazer push, o mesmo pre-flight. - O portão de preparação também se aplica — uma origem somente leitura, um pipeline em execução ou uma aba baseada em workbook o bloqueia com o motivo mostrado.
- Então o verbo de um plugin é uma edição preparada comum: um passo de
- Os predicados rodam constantemente.
AppliesTo, a construção de painel e o fornecimento de selo rodam a cada gesto e a cada tick de recomputação. Leia o snapshot que você recebeu; sem IO, sem rede, sem computação longa. - Exibido não é executado. O host reverifica o predicado no momento da invocação. Se a situação mudou desde que o menu foi desenhado, a resposta é um no-op honesto mais um redesenho, em vez de uma segunda falha. O navegador faz o mesmo para um id desatualizado.
ConfirmKeypergunta antes. Dê a uma ação uma chave de confirmação e o host mostra essa frase antes de executá-la — a coisa certa para um verbo que prepara várias células de uma vez.- Um nó
Linkabre apenashttp/https. A regra é um único predicado Core (StudioUiNode.IsAllowedUrl) que os dois hosts consultam, então eles não podem discordar sobre o que é seguro abrir; o navegador então reverifica a mesma forma antes de renderizar uma âncora, o que só pode recusar mais, nunca menos.- A url é armazenada exatamente como você a escreveu e recusada na ponta de abertura com um motivo, em vez de ser higienizada no momento do registro — o pacote que a escreveu deveria conseguir descobrir por que nada aconteceu.
- Painéis não guardam estado. Eles são reconstruídos a cada tick; o único lugar a que um valor pertence é a planilha (preparado). Se nada registra um painel, o painel simplesmente não é desenhado.
- Exceções são isoladas — um throw se torna um aviso de console em inglês e remove apenas aquela affordance, não a janela.
Quando a descrição não é suficiente — IStudioPanelProvider (assembly Editor)
Renderização arbitrária, entrada composta e fluxos de múltiplas etapas não têm vocabulário aqui, e inventar um significaria manter um mini framework de UI para sempre. Então o teto é deliberado e a válvula de escape é ampla: implemente IStudioPanelProvider no seu assembly complementar de editor e pinte o que quiser.
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…");
}Registre os dois sob o mesmo Id, e cada host usa o que consegue desenhar: o editor usa o rico, o navegador usa o descritivo. É assim que "até onde o navegador vai, o editor vai até o fim" se sustenta sem um segundo conjunto de contratos.
Não existe uma variante exclusiva da web — um painel rico ausente significa que o descritivo é desenhado, não que o painel desaparece. O elemento vive um tick de recomputação, então também não guarda estado.
4.17 Observando o pipeline (opcional)
Uma ponte entre produtos, telemetria de domínio, ou um gerador subsequente frequentemente precisa saber o que uma importação produziu sem reanalisá-la. Implemente IPipelineObserver e registre-o através de 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 ...
}
}- Observar não pode mudar o resultado. Você recebe um único snapshot imutável e não retorna nada. Deliberadamente não existe um hook para alterar um valor ou adicionar um diagnóstico: interpretar um valor pertence a um tipo de célula (§2), e relatar uma violação de regra pertence a um validador de domínio (§3). Misturar participação em um contrato de observação tornaria "observadores não podem mudar o resultado" falso na prática.
- Uma vez por ciclo de importação explícito, ao seu final, tenha ele sucedido ou falhado. Ele não roda na projeção de pre-flight que recomputa enquanto você prepara — nenhum código de terceiros fica preso à frequência de tecla.
- Uma execução com falha ainda relata o que analisou.
Tablescarrega as abas que foram analisadas antes de a validação falhar, o mesmo material que o fluxo de quarentena usa (Data Studio), então um observador vê um retrato verdadeiro de uma execução com falha, em vez de nada. - Duas lacunas honestas. O observador dispara a partir do próprio ponto de conclusão do ciclo de importação, então uma execução que nunca o alcança simplesmente não dispara.
- Uma importação abortada antes de o pipeline rodar (nenhuma configuração ativa, o portão do Addressables recusando).
- A etapa codegen→compilação sendo interrompida por um erro de compilação.
- Isso é um zero-disparo, nunca um disparo errado: se você precisa de "uma importação foi tentada", combine isso com o barramento
ImportEventsdo lado do editor.
- Um throw é isolado àquele observador, com o motivo coletado; a saída da importação não muda nem um bit.
- Futuros pontos de observação (logo após o parsing, um ciclo de exportação) vão chegar como interfaces de capacidade irmãs, descobertas fazendo cast do observador registrado, então adicionar um não vai quebrar uma implementação escrita hoje.
5. Origens de importação personalizadas (ISheetSourceProvider)
Uma nova origem (banco de dados, endpoint REST, formato interno) se integra com zero edições no Core/Editor. Implemente ISheetSourceProvider em um assembly Editor; o SourceProviderRegistry a descobre via TypeCache e ela aparece no dropdown "Source" das configurações, ao lado das opções embutidas. As quatro coisas que um provedor responde:
- Busca —
CreateTabSource(settings)retorna umITabSourceque fornece nome da aba → texto TSV bruto (assíncrono; problemas de ambiente são diagnósticos, não exceções; saída parcial permitida). - Gravação de volta —
CreateReflectTarget(dispatcher, settings)retorna umISourceReflectTargetque se conecta ao dispatcher de autoria (useSession/Callbacks/Baselinespúblicos do dispatcher para montar o seu alvo). Retorne um alvo somente se a sua origem puder ser gravada. - Visibilidade —
GetVisibility(settings)retorna quais campos de configuração o inspector deve mostrar para você. CanAuthor— retornefalsepara origens somente leitura; as janelas de autoria desabilitam a sua UI de edição (o mesmo que o Google ExportUrl).
A string estável Id persiste em sourceProviderId. As opções embutidas usam "LocalFile" / "GoogleSheet" como seus Ids; um sourceProviderId vazio resolve para o padrão embutido LocalFile. Um Id vazio remove o provedor da UI (útil para sondas de teste).
Os provedores vivem deliberadamente no assembly Editor — origens são o limite de IO, e manter o IO fora do Core preserva sua pureza (os outros três contratos são Core puro).
5.5 Ferramentas públicas para automação e integração
Além dos contratos de registro, existem cinco pontos de entrada públicos para código que conduz o SheetForge, em vez de estendê-lo — um script de CI, um build hook, o seu próprio botão de inspector, ou um segundo produto que faz o bake dos seus próprios assets a partir das mesmas planilhas.
Rode um ciclo — SheetForge.Editor.Pipeline.SheetForgeActions:
SheetForgeActions.RunImport(); // exactly what the toolbar's "Pull from source" does
SheetForgeActions.RunExport();
SheetForgeActions.RunPush();
SheetForgeActions.RunHealthCheck();Cada chamada é o ciclo inteiro: resolução de configurações, o portão do Addressables, exclusão mútua, modais de confirmação e aprovação, a barra de progresso, e a retomada codegen→compilação→bake através do domain reload. Não existe meio ciclo para montar, e portanto nenhum portão para pular por acidente.
Duas coisas a saber:
RunImporteRunPushsão fire-and-forget — seus corpos sãoasync void, porque a thread principal do editor não deve bloquear em IO de rede. O retorno, portanto, não é a conclusão; inscreva-se emImportEvents.ImportCompletedpara isso.- O Push ainda mostra o seu modal de aprovação, então um script desacompanhado não consegue enviar sem uma pessoa.
Pegue o mesmo lock que os embutidos pegam — para um provedor de origem personalizado gravando no seu próprio 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 responde à mesma pergunta sem pegar nada. O próprio lock permanece internal de propósito: se fosse público, chamar o seu End() poderia liberar a execução de outra pessoa — o scope torna isso impossível, porque só quem o detém consegue liberá-lo.
Termine uma gravação de volta da mesma forma que os embutidos terminam — AuthoringDispatcher.FinalizeReflectSuccess(writtenTabs) roda o final que um ISourceReflectTarget precisa alcançar:
- poda de retenção para as abas que gravou,
- o limite de confirmação
ClearUndo, - e a reimportação automática.
Os caminhos local e Google embutidos rodam o mesmo corpo, então o seu provedor termina de forma idêntica, em vez de aproximar. BuildProjectedTabs(), ao lado dele, entrega a você a projeção como TSV por aba — o que você está prestes a enviar — então um provedor pode pré-visualizar ou transformá-la sem gravar. Uma lista writtenTabs vazia é um no-op que mantém a preparação intacta.
Mostre as próprias frases do produto na sua própria UI — ImportReportText.Render(report) (Core.Tooling) retorna o relatório legível por humanos como uma string, sem nada escrito no console; SheetForgeActions.RenderReportText(report) é a mesma coisa, no idioma atual do editor do usuário. Use-o com AuthoringDispatchCallbacks.RenderReport para que uma segunda superfície de autoria relate falhas exatamente nas mesmas palavras que o produto usa.
Enumere uma aba cozida (bake) sem conhecer o seu tipo gerado — DefinitionDatabase.RecordsUntyped:
foreach (DefinitionDatabase db in myBakedDatabases)
foreach (object record in db.RecordsUntyped) // reflect on the fields you care about
;Este é o caminho sancionado para um segundo baker (um produto diferente transformando as mesmas planilhas nos seus próprios assets). Não use reflection sobre o campo privado records: fazer isso transforma um nome de campo em um contrato não declarado que quebra silenciosamente no dia em que o codegen o renomear. A lista é somente leitura — a planilha é canônica. Ela tem como padrão vazio em código gerado escrito antes deste membro existir; uma reimportação emite a sobrescrita.
Estenda as classes geradas com segurança — as duas classes geradas são partial, então um membro derivado (uma propriedade computada, uma implementação de interface, um operador) pode viver no seu próprio arquivo ao lado delas e sobreviver a toda reimportação. Não adicione campos serializados ali: o ScriptableObject cozido (bake) é reconstruído a partir da planilha a cada importação, então um campo que só a sua parte serializa volta ao seu padrão. Se um valor pertence aos dados, ele pertence a uma coluna.
O que permanece fechado — de propósito
As superfícies acima são a borda externa sancionada. As seguintes permanecem internal, não importa quão conveniente abri-las pareceria, porque cada uma é um limite de confiança ou de integridade, não um limite de conveniência:
- Credenciais e assinatura — o localizador de chave de conta de serviço, as primitivas JWT/PEM/PKCS8 e o provedor de token de acesso do Google. Abri-los entregaria a qualquer plugin um bearer token com escopo na sua planilha.
- A cadeia bruta de push (executor de push, gateways de planilha, gravações de célula) — a aprovação (
IPushApprover) é imposta dentro daquela orquestração; um gravador bruto público seria uma gravação de planilha sem nenhum passo de aprovação. - A verificação pré-envio e os motores de gravação do reflect — código externo entra apenas através de
AuthoringDispatcher.Reflect(), que passa por checagens de âncora obsoleta, validação de pre-flight e aprovação no caminho; o motor de gravação por baixo não é um contrato. - A cadeia de integridade de bake/codegen (fingerprints de esquema, gravador de código-fonte gerado, limpeza de órfãos) e o portão de atualidade do build — abri-los tornaria forjar ou contornar o estado do bake uma linha de código.
- A sobreposição efêmera de SO — "a planilha é a fonte da verdade" tem exatamente uma exceção sancionada (o alternador de edição de teste do inspector), e ela deliberadamente não é oferecida como API.
Se um fluxo de trabalho parece precisar de um desses, ele precisa de uma solicitação de funcionalidade, não de reflection.
6. Posicionamento do código gerado e namespaces
generatedCodeFolderpode ser qualquer pasta (o .asmdef complementar autorreparável conecta as referências de tipo de plugin automaticamente), mas colocá-la dentro do seu pacote (por exemplo,Assets/MyDomain/Runtime/Generated) é mais organizado — os tipos gerados então compilam no mesmo assembly que seus enums/tipos personalizados, sem precisar de um asmdef complementar.- Destino por aba: uma aba cujo tipo gerado já existe em algum lugar é regenerada no próprio lugar — a pasta
Generatedversionada do seu pacote permanece autoritativa mesmo que as configurações apontem para outro lugar. Duplicatas obsoletas são limpas automaticamente (registradas em log, nunca silenciosamente). generatedNamespaceisola seus tipos gerados (por exemplo,MyGame.Data). A descoberta de tipo usa o marcador intrínsecoSchemaFingerprintdos tipos gerados, não o namespace, então qualquer namespace funciona. Mudar o valor dispara automaticamente a regeneração.- Se você deve versionar a pasta
Generateddo seu pacote é uma política do seu pacote. O exemplo versiona a sua (classesExample*no namespace padrão, emSheetForge.Generated, abasExampleSkills/ExampleEffects/ExampleActions) para que um clone novo compile imediatamente, e o prefixo de nome de classeExample*— não um namespace separado — é o que evita a colisão com as abas reaisSkills/Effectsdo seu projeto.
7. Consumindo em tempo de execução — "monte, não programe"
O seu runtime lê os bancos de dados gerados e despacha, com base no enum type, para átomos de código:
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 shutdownPara lógica genuinamente procedural e pontual, referencie um asset de script via AssetRef — o SheetForge valida a referência e faz o bake do addressable (exatamente como uma imagem); executá-lo é trabalho do jogo.
8. Detectando o SheetForge a partir de outro asset
Um asset diferente — um que se integra ao SheetForge em vez de estendê-lo (um sistema de stats, por exemplo) — pode detectar que o SheetForge está instalado. Como um produto pago da Asset Store é um produto em pasta (sem package.json / UPM), ele não pode distribuir uma entrada versionDefines; em vez disso, o assembly Editor do SheetForge se autorregistra com um símbolo de scripting define SHEETFORGE em todo build target.
(a) Em tempo de compilação (preferido):
- Se a sua integração vive na sua própria assembly definition, adicione
SHEETFORGEàs Define Constraints desse asmdef — o assembly então só compila quando o SheetForge está presente. - Se código que toca o SheetForge compartilha um assembly com código que precisa compilar de qualquer forma, proteja apenas essas partes com
#if SHEETFORGE … #endif.
(b) Em tempo de Editor (alternativa): quando você não pode contar com a ordem de compilação, sonde via reflection — por exemplo, System.Type.GetType("SheetForge.Editor.Pipeline.ImportEvents, SheetForge.Editor") != null — e então conecte (por exemplo) o barramento de conclusão dinamicamente.
SHEETFORGE significa "o SheetForge está instalado". É separado de SHEETFORGE_ADDRESSABLES, um version-define interno nos próprios assemblies do SheetForge que só indica se o pacote Addressables está presente — não use o último como sonda de instalação.
O define persiste se o SheetForge for removido depois (não há um observador para desativá-lo); remova-o manualmente em Project Settings ▸ Player. Veja Capacidades e Limites.
O que ainda precisa de edições no Core
Tudo acima se integra com zero edições no Core. O que um plugin ainda não consegue fazer sem alterações no Core:
- Emitir valores de marcador no código gerado — marcadores personalizados são metadado de validação/exibição; fazer o bake deles em constantes ou atributos de codegen está fora do escopo até que um consumidor precise disso.
- Fazer com que a propagação de renomeação de chave alcance dentro de uma notação personalizada sem que se diga como — um registro renomeado é reescrito em células
RecordId@Tab, listas delas, e elementos de wrapper, por conta própria do Core. Para a sua própria gramática, implementeIReferencingCellType(§4.4a), e ele é reescrito com o payload preservado; isso é um opt-in, não uma edição no Core. Recuse o opt-in e o limite se mantém: o seu validador de domínio relata a chave agora pendurada, em vez de a renomeação corrigi-la silenciosamente. - Adicionar membros a um enum C# registrado por plugin a partir de uma planilha — um enum registrado com
enums.Register<T>()pertence ao código, então uma planilha de definição de enum não pode estendê-lo, e o Data Studio não oferece a linha. Mova o enum para uma planilha de enum, se a planilha deveria ser a dona dele (veja Sintaxe da Planilha).
O tipo wrapper <> (ICellWrapperType, veja §2) e o marcador estrutural personalizado (IStructuralMarkerDefinition, veja §4.5) ambos estendem o pipeline sem edições no Core.
Páginas relacionadas
- Sintaxe da Planilha — como tipos registrados aparecem nas planilhas
- Data Studio — onde as sobreposições de canvas, registros de código, widgets e ações aparecem
- Referência da API — a assinatura completa de cada contrato
- Kernel de Autoria — arestas e a superfície do motor
- Capacidades e Limites — os limites de extensão de plugin (regras de rejeição de wrapper, limites de marcador) e costuras reservadas