Capabilities & Limits
This page lists everything SheetForge does not do, cannot do yet, or does differently than you might expect, with the reason, the workaround, and whether there is future room.
Format per entry: What / Why / Workaround (+ future room where meaningful).
1. Platform & dependencies
Addressables is required — the pipeline locks without it
- What: address loading is the runtime path, and the
AssetRef@Grouptype needscom.unity.addressables, so the package must be installed to use the asset. The asset itself compiles without it — all Addressables-using code sits behind aSHEETFORGE_ADDRESSABLESversion-define that turns on only when the package is present. - Behavior when absent: the whole pipeline (import · export · push · authoring write-back) is locked, not degraded — running any entry point shows an install notice and stops. There is no partial or silent path (no "skip asset-key validation" fallback). Because the Editor compiles without the package, it never drops into Safe Mode. The Getting Started window opens normally, and its Addressables row shows ✗ with an Open Package Manager button. The dependency-free
SheetForge.Setupbootstrap remains as a guidance net for other, unrelated compile failures. - In the Data Studio without the package: the asset cell's ⊙ picker button and its drag-and-drop target are disabled, with the reason as a tooltip; typing an address into the cell still works.
- Why not a dual Resources/Addressables abstraction: deliberately not built — judged over-engineering.
- Workaround: install Addressables. The Asset-Store import prompt handles it before compilation; if you skipped it, the compiling Editor guides you to install.
- Verification: both branches are exercised live. With the version-defines removed (simulating "Addressables absent") the product and test assemblies compile with 0 errors; restored, 0 errors and 0 warnings. Independently verified end-to-end by importing the asset into a fresh project without Addressables installed: the project compiles and the install-guidance window appears as designed.
Unity Localization is optional — only the StringTable sync waits for it
- What: localization sheets (
@loc),LocRefreferences, key constants, coverage reports, Export, Push, xlsx and the web app all work withoutcom.unity.localizationinstalled. The one thing that waits is the StringTable synchronization exit: it shows an install notice (once per session) and stops. All package-touching code sits behind aSHEETFORGE_LOCALIZATIONversion-define, so every assembly and every line of generated code compiles without the package — generated fields are the plainLocRefstruct, never a package type. - Behavior when absent: nothing degrades and nothing is silently skipped elsewhere — the sheets stay complete sheets; only the sync exit is locked, with the reason shown.
- Supported version: 1.5 or newer.
- Workaround: install the package when you want the tables; everything authored before that moment syncs on the next completed import. See Localization Sheets.
No one-click programmatic install
- What: the safety-net window guides you; it does not install the package itself. The same rule covers the Unity Localization install notice.
- Why: Asset Store submission rules restrict programmatic package modification; a guidance window is the safe, compliant choice.
The SHEETFORGE product-detection define is not auto-removed
- What: the Editor assembly self-registers a
SHEETFORGEscripting-define symbol on every build target so other assets can detect that SheetForge is installed at compile time (see Plugin Authoring ▸ Detecting SheetForge from another asset). The registration is idempotent (added only when missing — no recompile churn once present). - Limit: if you later delete the asset, that define remains — the code that would notice the removal is gone with it.
- Workaround: remove it by hand in Project Settings ▸ Player ▸ Scripting Define Symbols (per platform). We deliberately do not keep a background watcher running just to clean one symbol. This is distinct from
SHEETFORGE_ADDRESSABLES, an internal version-define that only reflects whether the Addressables package is present.
2. Google Sheets source
ExportUrl mode is read-only
- What: Push, reflect, structure edit, and delete are all disabled in ExportUrl mode.
- Why: it is the unauthenticated, link-shared export path — read-only by nature. Push always requires SheetsApi credentials, enforced before any network call.
- Workaround: use SheetsApi mode (service account — see Google Sheets Setup) for any write-back.
ExportUrl requires a gid map
- What: an empty gid map fails the import; duplicate gids are rejected.
- Why: a gid-less export URL silently returns only the first tab — a silent-corruption trap, so the import rejects it. SheetsApi discovers tabs automatically.
- Workaround: register each tab's
#gid=value, or use SheetsApi.
Push deletes rows by key, and only verified ones
- What: a record deleted locally is removed from the live sheet on push — after the pre-send fetch confirms its key still sits on the row your import saw. A row already gone counts as done (idempotent re-push); a key found on a different row is skipped with a notice, never deleted by position. Deletions are listed in their own section of the approval summary and sent last, bottom-up per tab.
- Why: matching by key against the live sheet is what makes deletion safe on a sheet that may have drifted; anything the match cannot confirm is left alone.
- Limit: a source without the row-delete capability (a custom provider that never grew it) falls back to the old behaviour — the deletion is reported and the live row is left for you to remove.
Push skips conflicting cells (by design)
- What: cells edited by a third party since your import, rows whose key moved ambiguously, missing rows, or duplicate live keys are skipped with warnings — not overwritten.
- Why: this is the safety net working: sent cells are valid; skips protect other people's changes and prevent wrong-row writes.
- Workaround: check the applied/skipped counts in the report; re-import to reconcile, then Push again. (A conflict-resolution UI would be a separate feature — not planned.)
Push requires a key column
- What: a changed tab with no
RecordIdkey column cannot be pushed — it's a plan error that blocks the whole Push (zero send for every tab; no partial send). - Why: Push re-locates rows by key in the live sheet; without a key, the wrong-row guard cannot hold.
- Workaround: add a key column, or Export to a file and paste.
3. xlsx source
-
Tab rename excludes xlsx-origin tabs — multi-sheet workbook protection. Rename in the workbook, re-import.
-
Key-rename propagation into an xlsx-origin tab blocks the whole batch — the xlsx path cannot do surgical cell updates safely, and partial reflection is never allowed. Edit that tab directly, re-import.
-
Unrepresentable cells are rejected — formula cells without cached values, error cells, and tabs/newlines inside a cell. The built-in OOXML reader is intentionally minimal (zero third-party code). Materialize formulas; use
;for lists. -
Values only — formulas, dates and formatting are interpreted, honestly — a formula cell contributes its cached value (never recalculated), a date-formatted cell is read as
yyyy-MM-dddisplay text, and number formats, merged cells and charts are not imported. The web app's import dialog names what actually happened in a "How this workbook was read" note; in the editor the same policy applies silently per cell (the rejections above still report per cell). -
Some exported dropdown rules cannot be carried — the export is one workbook, so a reference column's dropdown is written as a real range over the target sheet's key column, the same meaning the Google rule has. Three cases are still left out, and named together in a single
DropdownNotSupportedByFormatwarning:- a list member containing a comma (the inline separator would split it);
- an inline list over the format's 255-character cap (quotes included);
- a range whose target tab is not in the workbook.
The values export in full either way. See Sources, Export & Push.
4. Authoring — the Data Studio
A tab with no key column takes no new records, and its value edits lose their anchor
-
What: a tab without a
RecordIdkey column imports and displays normally, and structure editing works fully — adding, removing, renaming, reordering columns and markers, plus the sheet-level rename and delete. What it cannot gain is a new record, since a record with no key cannot be named or referenced:- the add-row control is disabled;
- the reference picker refuses to create there ("… has no key column, so a new record cannot be created there");
- a canvas or inspector action that tries to write into it does nothing.
Value cells are editable — but with no key to address the row by, the edit is staged against the row's position only.
-
Why: every staged edit is normally addressed logically, as
(tab, record key, field), and re-resolved against the sheet just before it is written. That is what lets an edit survive a re-import, a row reorder, or someone else inserting rows above it. With no key column there is no such address, so the edit passes through pinned to a row number instead — outside that safety net.So a position-anchored edit can land on the wrong row if the sheet's rows move underneath you before you write back (a re-import, or someone editing the source directly). Stage and reflect these in short batches.
-
Workaround: add a
RecordIdcolumn (structure editing is available, so you can do it in the same window), reflect, and the tab becomes fully authorable with the logical anchor back in place. Keyless tabs remain perfectly valid to import — this is an authoring limit, not a schema one.
Column rename / @type change breaks referencing game code; irreversible after reflect
- What: the generated field's name/type changes; game code referencing it must be updated by hand. Ctrl+Z works only before reflect.
- Why: strong typing — the field is part of the generated schema. A compile break is caught by the auto-chain's safe abort with an actionable sentence. Column values are fully preserved (only marker cells change).
- Workaround: the confirmation dialog warns first; update your code and let the next import resume.
Tab rename breaks referencing game code; irreversible after reflect
Same mechanics as above — the generated class name changes (FooDatabase → BarDatabase); re-import handles all asset-side cleanup automatically (old class, SO, address).
Mutual (swap) and cyclic tab renames are supported
- What:
Alpha→Beta+Beta→Alpha(a swap), and longer cycles (A→B→C→A), can be staged and reflected in one batch. Staging either half first works, and the tab bar shows the exchanged names immediately (WYSIWYG, undoable). The UI gate uses final-name-set uniqueness — only a real conflict, two renames targeting the same name, is rejected. The reflect enforces it strictly. - References follow the data (tab identity), not the name: after an A↔B swap,
RecordId@Ais atomically rewritten toRecordId@B(single-pass — never double-applied), so it keeps pointing at the same data, which moved to B. - Local: a swap exchanges the two files' contents in a single write pass. A chain that reuses a name across a different extension deletes the stale old-extension file (path-based delete guard), so re-import never sees a duplicate tab.
- Google: title changes are topologically ordered and break any cycle with a temporary title (
A→tmp, B→A, tmp→B), so the live sheet never holds a momentary duplicate title. If a title change fails mid-sequence, the tab left under a temporary name is reported with recovery guidance.
Google-only edge: swapped tabs that reference each other are not repointed
- What: when the two swapped tabs reference each other — tab
Ahas aRecordId@Bcolumn and tabBhas aRecordId@Acolumn — the Google path preserves their content via the in-place title change and does not rewrite their own@typecells. So that mutual self-reference is not repointed on Google. - Why: Google renames a tab by changing its title (content untouched, by design); rewriting the renamed tab's own grid would defeat that. Local sources rewrite the renamed tab's projection, so local handles this fully. References from a third tab are repointed on both paths.
- Workaround: on Google, route the mutual reference through a third tab, or reflect the swap via an intermediate name.
The staged-value SO overlay has no button any more
- What: the "preview staged values on SOs" overlay (
EphemeralSoApply) was driven by a Workbench button, and that window is gone. The type remains public API for a tool that wants it; the SO inspector's test edit toggle covers the everyday case of trying a runtime number. - Limit if you do call it: the overlay declines — with a badge — (a) pending/new columns and (b) parse-failure cells. New rows are supported. It reuses the real parse+bake path, so what it cannot truthfully compute it declines rather than fakes.
- Workaround: it was only ever a preview; reflect normally for the real change. Re-import always restores the truth.
Staged edits isolate when their row is externally renamed, deleted, or key-conflicted
- What: a staged edit whose row was externally renamed, externally deleted, or key-conflicted between staging and reflect is excluded from the reflect and badged "isolated".
- Why: its logical address can't be re-resolved — but it is neither silently dropped nor allowed to block the session.
- Workaround: discard it individually (after confirmation) and re-stage.
Key-rename propagation covers baseline cells only
- What: text you just typed in the same batch referencing the old key is not auto-rewritten.
- Why: silently rewriting the user's fresh input is forbidden; pre-flight catches the dangling reference instead.
- Workaround: fix the staged reference yourself, or reflect the rename first.
The remaining entries are all about the Data Studio, the one authoring window. The older Workbench window has been removed; the three features it alone offered moved into the Studio and the settings inspector first — see what happened to the Workbench.
A sheet that failed validation opens for editing — but Export, Push and builds stay blocked
- What: if the source was read completely, its sheets are saved as the baseline even when validation failed, so the Studio can open them and you can fix the errors in place. Code generation and baking do not run until the error count reaches zero, and Export, Push to the live sheet, and player builds are all refused while the sheet is in that state, each saying why.
- Why: those three exits all combine the last successfully baked values with the newer sheets. Running one now would splice stale values over cells someone has already corrected — a silent rollback. Blocking the exits is what lets the entrance stay open.
- Reflecting a fix asks once: on a quarantined sheet the write-back shows an extra confirmation, because pre-flight cannot be the hard gate there (the sheet already has errors). Everything found is reported as a warning in that reflect, and the automatic re-import re-validates the whole sheet. Healthy sheets are unaffected — pre-flight still refuses to write.
- Workaround: fix every reported error and pull again. The block lifts by itself at the one place that clears it, a run that completes through bake.
Data Studio sort and filter are view-only — and disable row reorder while active
- What: the Studio's per-sheet sort (any column, asc/desc, persisted per project) and text filter change the display order only. The gutter keeps real sheet row numbers, and neither affects staging, reflect, push, or export. While a sort or filter is active, the row ▲▼ reorder tools are disabled with a tooltip.
- Why: reordering by "visible neighbour" while the view is sorted or filtered would silently move rows next to lines the user cannot see. Real row order changes are a structure operation — clear the sort/filter first.
- Note: "sort by newest" only exists if your sheet has a column that encodes it (e.g. an
IntIdor a date-like string column) — the sheet itself stores no timestamps.
Data Studio problems are a draft while a key rename is staged
- What: while a key (
RecordId) cell has a staged edit, the Problems panel carries a draft badge, and unresolved-reference entries in it may be false alarms. - Why: the in-memory preview does not apply key-rename propagation — that runs at reflect time, across every tab. Rather than hide the diagnostics or fake the propagation, the window tells you the list is a draft until the rename is written.
- Workaround: reflect the rename (propagation runs with its own confirmation), then read the refreshed list.
The table is row-virtualised above 200 rows — with two edges worth knowing
-
What: past 200 rows the table builds row elements for the visible window only (plus twelve rows of overscan), with spacers above and below holding the true total height so the scrollbar does not lie. Scrolling across a window boundary reuses the rows that survive and builds only the ones that entered. The browser grid does the same, at the same threshold.
Two cases still build everything. At or below 200 rows every row is built exactly as before, bit for bit. So is a table whose viewport height cannot be asked for at all — one standing outside a window, where layout never arrives — because there the honest fallback is "build everything". A large table that simply has not been laid out yet waits one frame instead, so it is windowed from its first paint rather than building everything and throwing it away.
-
The row you are editing stays alive even after it scrolls out, so the caret, the focus and what you typed survive. That keep-alive has a distance cap, and past it the open editor commits and blurs rather than being carried indefinitely. Nothing is lost when that happens — the value is already in the staging session.
-
Only element creation is windowed. Column-width sampling, search, sort, coordinates and the staging overlay still consider every row, because each of them would give a different answer if it only looked at what is on screen. So switching to a very large sheet still does work proportional to its size; what it no longer does is build thousands of widgets.
-
In the browser, windowed mode measures the columns instead of letting the layout do it. Auto-layout widths would be computed from whatever rows happen to be in the window, so a column would twitch as you scrolled. In windowed mode the widths come from a data-driven estimate over all rows and are then fixed. Full-render mode (≤ 200 rows) still uses auto-layout, unchanged.
The canvas pans only within its scroll range, and its dashed cycle wires get coarser as they grow
-
What:
- Ctrl/Cmd + mouse wheel zooms the record canvas between 25 % and 200 %, keeping the point under the cursor still — a centre-anchored zoom would slide the card you were looking at off screen. The percentage in the canvas header is a button that returns to 100 %. A plain wheel still scrolls.
- Dragging with the middle mouse button — or Alt + left button, for hardware without one — pans, and the cursor marks the grab while it is held. Left-drag is left to selecting and linking, so it could not also mean "move the view".
- The pane is a scroll view, so the pan range is the scroll range: it stops at the edge of the content instead of drifting into empty space, and when the content is smaller than the viewport it does not move at all. This is not an infinite canvas.
- A dashed wire marking a cycle caps how many dashes it draws and doubles its dash period on a long path, so a very long loop reads coarser rather than crisper.
-
Why: Unity allocates mesh vertices per draw call with a hard 65,535 ceiling, and going over it makes the drawing vanish entirely while still paying for the tessellation. The dash cap keeps one
Strokeinside that budget by design.The background dot grid used to sit on the same cliff and no longer does. It is a small repeating background tile, which costs zero vertices and repaints in constant time however large the canvas grows. (One dot drawn as a path costs a measured 28 vertices, not its four corners. That is the arithmetic behind the drawing fallback's 1,800-dot budget, and the reason the tile is the shipped path.)
-
Workaround: none needed for the grid. For a large neighbourhood, zoom out, narrow the direction segment, or open a neighbour as the new terminus rather than trying to fit everything on one screen.
A sheet with no table yet is skipped, not imported
- What: a tab that has none of the three required markers and no data row — a brand-new sheet holding only comments or an
@styleline — is skipped with anEmptyTabSkippedwarning. It does not fail the import on three missing markers. Its already-generated code, baked asset and address are preserved, not cleaned up as if the tab were deleted. Export and Push skip it symmetrically, because all three ask the same predicate. - Why: one unfinished sheet must not be able to stop every other tab from importing, and an author usually creates the sheet before its header row.
- Boundary: a half-written sheet (any required marker present) is not skipped — it fails honestly, because silently skipping it would hide real work. A sheet typed from column A rather than column B is likewise handed to the parser, so its real diagnostic ("column A is the marker column, data starts at B") survives.
An enum registered from plugin C# cannot gain members from a sheet
- What: an
Enum<T>whoseTa plugin registered withenums.Register<T>()is owned by code. An enum definition sheet may not claim that name (DuplicateEnumName), and the cell dropdown's "Add a new member…" row is simply absent on such a column. - Why: the sheet is canonical only for what the sheet defines. Writing a member into a sheet that no longer decides the compiled type would produce a member that never appears in code — a promise the product cannot keep. The absent row is how the UI says so, rather than offering an action that would fail.
- Workaround: move the enum into an enum sheet if the sheet should own it, or add the member in your plugin's C# and re-compile.
- Structure follows the same ownership line: an enum definition sheet's structure is fully authored in both hosts — define, rename, delete, reorder columns, edit the underlying type and description — but none of it can touch a code-owned enum name, and a definition sheet cannot claim one either. The refusal names the reason.
Enum definition sheets: members only append, and there is no sorting
- What: the enum sheet's structure is authored in place in the editor and the web app alike, but member rows only ever append — a gap is never back-filled — and the view offers no sort or filter.
- Why: a member's position is its integer value. Filling a hole or rearranging members would silently renumber values already baked into assets and stored in saves. Column order, by contrast, carries no meaning, which is why reordering columns is always allowed.
- Workaround: to pin a value explicitly, use the
Name=valuesyntax; presentation order elsewhere is the consumer's concern, not the sheet's.
A sheet-defined enum always generates into the settings folder
- What: generated tab types regenerate in place at whatever folder they already live in. The enum file (
SheetForgeEnums.cs) has no tab to anchor to, so it is always written to the generated-code folder named in the settings. If a tab whose generated code lives in its own package folder uses a sheet-defined enum, that package assembly fails to compile withCS0246. - Why: one file holds every sheet-defined enum, because an enum is a project-level output rather than a per-tab one — so there is no single tab whose home it could follow.
- Workaround: put both generated folders in one assembly, or register that enum from plugin code instead. The failure is a visible compile error with the missing type named, never silent corruption.
Typed asset references resolve against loaded types — short name only when unique, no predefined-assembly types
- What:
AssetRef@Group<Type>accepts anyUnityEngine.Object-derived asset type the project can load, engine or your own, with no allow-list. Three things are refused rather than guessed: a short name that several loaded types share (AmbiguousAssetType—TextAssetcan be one, depending on the packages installed) must be written as the full name (UnityEngine.TextAsset); an unknown name isUnknownAssetTypewith a nearest-match suggestion; and a type that lives in a predefined assembly (Assembly-CSharpand its siblings — any script folder without an assembly definition) isAssetTypeNotReferenceable. - Why: the generated companion assembly is an assembly definition, and an assembly definition cannot reference the predefined assemblies —
AssetReferenceT<T>for such aTwould not compile. Resolving an ambiguous name by picking one would silently bind the column to the wrong type. - Workaround: move the type into an assembly definition, or drop the
<…>restriction and keep the unrestrictedAssetRef@Group. Components and editor-only types are never candidates. - Also: the browser does not resolve type names (it has no project to resolve against): the web app parses
<Type>and shows it in the column tooltip, but produces none of the three type-name diagnostics and offers no picker or drop. Codegen never emits an unresolved name verbatim — a name it cannot resolve falls back toAssetReferencewith anAssetTypeUnresolvedFallbackwarning.
The asset picker, drop and staged registrations — what is and is not automatic
- What: dropping or picking an asset writes the address into the cell at once and stages the Addressables change (add · move · create group) for the reflect; the change runs only after the sheet write succeeds — or, when registrations are the only thing staged, on their own, followed by the automatic re-import; a reflect that could not write because its tabs are workbook-backed keeps them staged. A registration nothing references any more is dropped when the cell is retyped, and any that survives to the reflect is skipped as unreferenced; an asset already in the group keeps its existing address; an asset in another group is moved only after a confirmation that names the other cells referencing it. The auto-address is the file name without extension, and an address already used by a different asset in that group is refused rather than renamed. New groups get the default
BundledAssetGroupSchemaandContentUpdateGroupSchema. Applied and skipped items are logged to the Console with their reasons, and for a local folder source the reflect's completion dialog repeats the summary (Addressables: N registered, M skipped). - Why: the sheet is canonical — the project must never change for a reflect that did not reach the sheet, and an entry no cell points at would be an orphan the sheet does not explain.
- Workaround: if a registration was skipped, the next re-import reports the cell as
UnknownAssetKey; fix the cause and reflect again. Registration always requires the Addressables package.
Sub-assets are addressed as parent[sub], and a texture in Sprite mode passes <Sprite>
- What: a sub-object entry (a sprite in a texture, a material in a font) is addressed the way Addressables names it —
parent[sub]— and that key is checked against the sub-object's own type. The parent address satisfies its own type and every sub-asset type it contains, which is what lets a texture imported in Sprite mode pass an<Sprite>column. Dropping a sub-asset stages the parent for registration and writesparent[sub]into the cell. - Boundary: a sub-object entry has to exist in the Addressables catalog for
parent[sub]to validate; the picker lists the sub-keys it knows after their parent.
Colours have no HDR, curve tangents follow their mode, gradients are quantised — by design
- What:
Coloris four bytes — a channel above 1 (HDR) is clamped to 0…1 on Export. AnAnimationCurvetangent whose side isAuto,Linear,ConstantorClampedAutois recomputed from the mode at import, so a hand-typed number that contradicts its mode is replaced (the same recomputation Unity performs when the mode is applied);Onceis read asClampForeverand never written back; a curve with no keys has no text form and exists only as the empty cell of an optional column.Gradientkey times are quantised to 16 bits on import (exactly as Unity stores them), a single-key gradient comes back from Unity as two identical keys, and the colour space is written only when it was set. - Why: the value the sheet shows must be the value the engine holds, so the normalisation Unity would perform later is performed once, at the entrance, and every surface — sheet, editor field, web preview, baked asset — shows one curve and one gradient.
- Workaround: use
Free/Freewhen you want the tangent numbers taken literally; store HDR intensities in a separatefloatcolumn.
Chip editors and native fields exist for the three visual types only
- What: the Data Studio shows
Color,AnimationCurveandGradientscalars as Unity's own fields and theirList<>s as chip editors; the web app shows previews with its own editors and chip lists. Every other list column —List<int>,List<Enum<…>>, wrapper lists — stays canonical text in both hosts, and a wrapper containing one of the three (Pair<Color>) is text too. - Why: the three types are the ones with a picture per element; for the rest a single canonical line is already the most exact representation, and a wrapper's outer notation is owned by its plugin.
- Room: a plugin type that stores one of the three values can adopt the same editors by declaring the matching
StudioCellEditorHintarchetype (see Plugin Authoring §4.16).
Ownership colouring is sheet-versus-code only
- What: the sidebar/legend distinguishes exactly two origins — real sheet tabs and code-registry virtual tabs. There is no third "generated" classification, and no per-column ownership colouring.
- Why: origin is derived from the tab itself, which the window already knows for certain; a per-column classification would need yet another extension point to be truthful, and no consumer asked for one.
- Not the same thing as a sheet's colour:
@stylelets a sheet name its own colour, and that colour is display metadata the author chose — it says nothing about where the sheet comes from. The two colourings are read from different places and never merge.
Reference drop-downs answer membership, not order
- What: a
RecordId@Tabcell now has a searchable drop-down (and a checklist forList<>), but the list cell's drop-down only adds and removes elements — it cannot move one. Reordering a list is done on the canvas, where each element has its own row. - Why: a drop-down answers "what is in here"; "which position" needs a surface that lines things up, which the canvas already is. Duplicating it in two places would be two answers to maintain.
- Also: the drop-down attaches to a plain, non-wrapper reference column only. A wrapper cell's text carries the wrapper's own notation, so pasting a bare key into it would destroy the value — reaching inside a wrapper is a registered cell-widget's job (see Plugin Authoring).
The All search reads baked data in the editor and the live session in the browser
- What: the Data Studio's All entry searches the baked databases, so it needs one successful import first and refreshes itself when an import completes or the active settings change. The web app's All entry searches the values the session shows right now, staged edits included. Matching, result order and the 50-row page are the same code in both, and in both a result is double-clicked (or Enter on the selected row) to open that sheet with the matched cell selected.
- Why: the browser has no baked ScriptableObjects; what it has is the live session — and answering with the value on screen is what a browser session is for. The editor keeps reading the baked truth it already has.
- Boundary: in the editor, an edit that is staged but not yet reflected is not found by All until an import has run, and a result from a sheet the session has not loaded does not navigate — a notice under the list says why. In the browser a staged edit is found at once and every result can be jumped to.
5. Performance
- Normal path is linear and fast: 50,000 rows × 20 columns ≈ 628 ms (live editor, Mono; 144 ms headless); 50 tabs × 2,000 rows with 180k reference cells ≈ 294 ms. Typical project sizes are a non-issue.
- Memory is linear but boxing-heavy: ≈ 59 bytes/cell retained (≈ 138 peak during import). At the Google-Sheet cell ceiling (~10M cells) that extrapolates to ~6.3 s import, ~590 MB retained, ~1.4 GB peak — mind low-spec/32-bit contexts at extreme sizes. (A column-oriented IR is an acknowledged backlog item.)
- The authoring table is row-virtualised past 200 rows in both the editor and the browser, so opening a large sheet no longer builds a widget per row. What is not windowed is the per-row logic that would answer differently if it were — width sampling, search, sort, coordinates, the staging overlay. Details and the two edges are in §4.
- The error path is linear too: even when references break en masse, nearest-suggestion computation stays bounded. A per-field suggestion budget plus a length-prefiltered, early-terminating edit distance keep it roughly linear in the number of broken references (≈ 45 ms at 4,000 broken references, headless; valid data at the same scale ≈ 2.7 ms). Renaming a referenced tab does not break references en masse in the first place: the rename rewrites the referring
@typecells.
6. Demo scene
- The samples are selective imports — the two demo samples and their scenes are not present by default. They ship as Unity packages (
Assets/SheetForge/Examples/SheetForgePluginDemo.unitypackageandSheetForgeCoreDemo.unitypackage). Import one — double-click, or the Getting Started window's Import Plugin/Core Demo button — to restoreAssets/SheetForge.PluginDemo/…orAssets/SheetForge.CoreDemo/…. Until then the samples are not in your project at all — they ship only as those packages — so they can never collide with your project. The core product is fully self-sufficient without them. - Requires one import first (per machine) — the Addressables addresses it loads are a non-committed cache. Before that, it shows a guidance message.
- No namespace setting needed for the demos — the committed example types use the default
SheetForge.Generatednamespace with anExample*class prefix, so re-importing a demo regenerates them in place with nogeneratedNamespacesetting.
7. Plugin extension — shipped seams (with boundaries) and what's still reserved
Sixteen extension contracts ship, every one of them joining with zero Core edits — the full set is in Plugin Authoring. All sixteen are found by discovery (Unity's TypeCache; the browser's uploaded-assembly scan), with no assembly reference and no manifest to edit. The eleven Core ones are handed a registry to register what they add into, while the five Editor ones — graph widget, inspector action, cell-editor provider, panel provider and source provider — are discovered and used as they are.
Five of the shipped seams carry boundaries worth stating here rather than leaving you to discover them:
Referencing custom cell types — full RecordId@Tab parity for your own notation
-
What: a registered cell parser that also implements
IReferencingCellType(and whose value implementsIRefBearingValue) tells the Core how to read and rewrite the key buried in its own notation. That column then gets everything a built-in reference gets:- integrity checking with nearest-match suggestions;
- key-rename propagation that preserves the payload (
attack:add:10→power:add:10); - graph edges and ports, the
▾picker, back-reference counts; - orphan detection and exported dropdown rules.
Discovery is a cast of the already-registered parser — there is no new registration channel, and a custom type that does not implement it is unchanged bit for bit. See Plugin Authoring §4.4a.
-
Boundaries: a payload may not contain
;— the Core splits a list cell into elements before your parser ever sees the text, so a semicolon inside a value would be shredded into two elements (the same constraint wrapper types carry). And@targetmust name a real sheet tab: a code registry's virtual tab is rejected withUnknownTargetTab, exactly as it is forRecordId@Tab. That restriction is what lets the Core's own unresolved-reference reporting and rename propagation apply unmodified.
<> wrapper types (MyWrapper<T>) — with rejection rules
-
What: a plugin registers a generic value shape (e.g.
Pair<int>=1~2) viaICellWrapperType; the Core resolves the inner type recursively (see Sheet Syntax). -
Rejection rules:
Pair<List<T>>is rejected — a list may not sit inside a wrapper, andListstays flat and outermost.Pair<int>@Tabis rejected — put the@on the inner leaf:Pair<RecordId@Tab>.Pair<int?>/Pair<int=1>are rejected — optionality/defaults are field-level, not part of the inner type.
List<Pair<T>>is allowed, but the wrapper's own delimiter must differ from;(the list separator) — a plugin-authoring responsibility the Core cannot enforce.
Custom structural markers (@yourMarker) — for per-column metadata only
- What: a plugin registers a
@markerrow viaIStructuralMarkerDefinition/ISheetForgeMarkerPlugin, generalizing@overlap's per-column validation. The value is stored asFieldSchema.MarkerValuesmetadata. - Boundaries: a marker owns only its per-column value validation — it does not take over parsing a whole-row data shape (normalization stays the way to express data shapes). And codegen does not bake marker values: like
@overlap, they are validation/display metadata only, invisible to the schema fingerprint — so nothing marker-related reaches the generated code or the baked SO.
Colour presets — the surfaces we paint, not Unity's widgets
- What: a plugin registers a colour preset via
ISheetForgeThemePlugin/ThemeRegistry. It appears inPreferences ▸ SheetForge ▸ Themenext to the built-in Default and High contrast presets, and applies only if the user picks it (registering never hijacks the screen). A preset overrides just the slots it names — every other slot keeps the product default, so presets stay valid as slots are added. - Boundary — mixed chrome is expected: the theme covers what SheetForge paints itself (window backgrounds, headers, text, accents, grid and staging colours). Native Unity widgets drawn inside those windows — button chrome, field borders, popup arrows — keep following the editor skin, which Unity does not let a package restyle. So choosing Always light while the editor runs the dark skin gives a light SheetForge surface with dark native widgets on it. Set the editor skin to match if you want a uniform look.
- Boundary — colour only: presets carry colours (
0xRRGGBBper slot). Spacing, font sizes and layout are not themable, and translucent fills (badge backgrounds, the modal scrim) derive from a slot colour plus a fixed alpha rather than being separately settable.
Declarative authoring surfaces — a bounded vocabulary, on purpose
- What:
ISheetForgeStudioPluginlets a pack describe verbs, panels, column badges and cell-editor shapes as data, so one registration is drawn by the editor and by the browser alike. The vocabulary is fixed and grows only by appending: five action placements, thirteen node kinds, seven cell-editor archetypes (the two newest,CurveEditorandGradientEditor, are the ones the built-in curve and gradient types use). - Boundary — it is not a UI framework. Arbitrary rendering, composite input and multi-step flows have no words here, and adding them would mean maintaining a miniature UI toolkit forever. That is what
IStudioPanelProvideris for: register it under the same id as a described panel and the editor draws the rich one while the browser draws the described one. There is no web-only escape hatch — the browser cannot load a UIToolkit type, and pretending otherwise would put a plugin's extension on one screen only. - Boundary — an action's powers are exactly four: stage one cell, stage several cells as one Undo step, focus a record, request a redraw. A plugin's verb is therefore an ordinary staged edit that passes the same gate, pre-flight and push as a hand-typed one. The authoring session itself is deliberately not exposed.
- Zero-fire, never wrong-fire, for observers:
IPipelineObserverfires at the end of an explicit import cycle. Two paths never reach that point at all — a run that stops before the pipeline starts (no active settings; Addressables not installed) and a codegen→compile leg interrupted by a compile error. If you need "an import was attempted", pair it with the editor-sideImportEventsbus.
Still designed but not built (no consumer yet)
- Whole-row data-shape markers (e.g. one marker that reads a 2D matrix as a single field) — deliberately not built: a marker owns per-column validation, not row parsing, and normalization (references +
typecolumn +List<T>) is expressively complete. TheMarkerRegistryregistration seam itself ships; only this shape-parsing interpretation is reserved. - Baking custom-marker values into generated code — out of scope until a consumer needs marker metadata as codegen constants/attributes.
- Per-record / lazy-load SO containers — design-complete, unbuilt; current codegen produces per-tab whole-load Database SOs only.
8. Schema evolution
- Export/Push require a fresh bake after a schema change — a stale bake fails with
ExportSchemaMismatch(fingerprint mismatch). The refusal now offers to run that import for you: one confirmation starts it, and nothing is exported or pushed automatically afterwards — you press the original action again once the import lands. - First import after a schema change is internally two-stage (codegen → compile → bake) — automatic, one user action; only a compile failure stops it (safe abort, actionable sentence, attempt cap 3).
9. Localization scope
Report detail fragments (offending values, suggestions), low-level exceptions, and developer logs are inline English inside the localized report skeleton — runtime interpolations can't be language-table keys (industry-standard boundary). Everything editor-drawn plus the report skeleton and why/how sentences is fully localized in all 10 languages.
All ten languages are translated in full. Every key in every language table carries a real translation — the Data Studio, the theme preferences, dialogs and log lines included. Key parity across the ten files is test-enforced, so nothing falls back to a raw key or breaks a placeholder.
A handful of entries per language do read exactly as the English one, and that is a translation decision rather than a pending pass: they are symbols and placeholder-only strings (—, +), proper nouns and format names (Google Sheets, SHA-256), and words a language genuinely spells the way English does (OK, Alpha).
A plugin's own labels are not in the Core table at all: register them with ISheetForgeStringsPlugin to have them follow the user's language, or leave them unregistered and they are shown verbatim.
10. Editor interactions
- Ctrl+Z scope: a focused text field consumes Ctrl+Z first (OS standard); after a successful reflect, staging history is cleared — undo never reaches into what was already written to the sheet (the sheet is canonical).
- Language change is locked during Import/Export/Push (it triggers a menu-regeneration recompile). Theme changes are not locked — they never recompile, so brightness and preset can be switched at any time, including mid-run.
- Theme and language are per-user (EditorPrefs), not per-project — teammates each keep their own, and neither shows up in version control. Picking a non-default colour preset writes one generated stylesheet under
Assets/SheetForge/Editor/Generated/(gitignored, self-healing); the default preset writes nothing and deletes it. - Generated code + baked SOs + the Addressables group are per-machine, gitignored caches — each machine runs an import once; game code loads by address, never by direct scene reference.
11. Licensing
The repository ships a LICENSE notice: the Unity Asset Store EULA is the governing agreement, with a repository-viewing notice (source visible for reference and for licensed purchasers; no redistribution/resale outside the EULA without written permission). Third-party code: none — including the hand-written OOXML xlsx reader/writer.
12. Verified state (at release)
- Dual test harness: 2,150 headless .NET tests (2,150 passing) + 3,021 EditMode tests (3,021 passing, 0 failures, 4 skipped). These two numbers are the single place the counts are stated; every other page links here.
- The four skips are the live Google round trip, which runs only when service-account credentials are present in the environment and was skipped in this run. With credentials it has been exercised repeatedly against a real spreadsheet — fetch → push, including row-moved conflict detection, locale-independent float handling, a cross-tab mixed batch, and a full dispatcher run asserting one merged report and exactly one auto re-import per send. Result: 4/4 green.
- The web app has its own gates, all green: type-check, lint, 414 unit tests, a WebAssembly publish + smoke run (which loads a real plugin DLL), a production build, 56 end-to-end browser tests, and a cross-language constant check (six Unity↔web pairs: host version, plugin format, registry schema version, service-account Sheets scope, host assembly names, row-window thresholds).
- All compiled product asmdefs: 0 errors, 0 warnings. (The sample asmdefs — two in each demo — arrive only when you import a demo package, and are not compiled until then.)
- Guard tests green: zero Korean literals in product source, zero domain vocabulary in kernel seams (both skip
/Samples~/— the sample is domain content), 10-language key parity. - The no-IVT consumer-simulation assembly compiles against the public API alone (compiler-enforced). A no-IVT mini-plugin probe there implements fifteen of the sixteen extension contracts (
ISheetForgePlugin/ validator / edge / marker / template / graph / code registry / theme / studio UI / strings / pipeline /ISheetSourceProvider/ Studio widget / Studio inspector action / Studio cell editor) using the public surface alone, so contract publicity stays proven even though the Plugin Demo sample is not compiled. The sixteenth,IStudioPanelProvider, returns aVisualElementand is exercised by an editor-side test instead. The same probe also implements the opt-in capability interfaces, includingIReferencingCellType/IRefBearingValue, and exercises them through the public surface (discovery by cast, all five hooks, residue preservation).
Items that can only be verified at Asset-Store submission time (not in the repo): the .unitypackage install prompt behavior, Portal dependency declaration, exclusion of test assemblies from the distributed package, and a clean-project 0-warning re-check.
13. Localization sheets (game text)
The boundaries of Localization Sheets and the Unity Localization bridge, honestly stated. (The package itself is optional — see §1.)
The bridge is one-way, and external table edits are asked about — never merged
- What: synchronization goes sheet → StringTables only. The bridge stamps the tables it owns and fingerprints each sync; a table edited by anything else since — the Localization Tables window, Unity's own Google Sheets extension, an XLIFF import — makes the next sync stop and ask: overwrite from the sheet, or abort with a difference report.
- Why: two writable cockpits over the same data end in silent overwrites. The sheet is canonical, so the other cockpit must be explicit, not silent.
- Workaround: route translations through the sheet — the translation workbook (xlsx export + locale-only partial re-import) exists precisely for that.
A key renamed outside the Studio is a delete plus an add
- What: renaming a key in the Data Studio renames the table entry in place, preserving the internal id
LocalizedStringreferences bind to — scene references survive. Renaming the key directly in the sheet source (Google Sheets, Excel) is indistinguishable from removing one key and adding another: the bridge creates a fresh entry and the old one becomes an orphan, with scene references still pointing at the orphan. - Why: text-level diffing cannot tell a rename from a delete-plus-add without guessing, and a wrong guess would silently rebind references.
- Workaround: rename keys in the Data Studio (either host); the orphan report catches the aftermath of an outside rename.
Orphan table keys are preserved by default
- What: a key present in the table but no longer in the sheet is kept, reported as an orphan, and removed only through the explicit cleanup action — or automatically, if you opt into the delete on sync setting. Nothing is deleted as a side effect.
- Why: a missing sheet row can be a mistake mid-edit; destroying translations over it would be unrecoverable.
String tables only — asset tables are not covered
- What: the bridge fills
StringTablecollections. Unity Localization'sAssetTableaxis (localized sprites, audio, prefabs) is not synchronized from sheets. An acknowledged backlog item. - Workaround: manage asset tables with the package's own tools; they are untouched by the bridge.
XLIFF and pseudo-locales are not re-implemented
- What: the synced tables are ordinary Unity Localization tables, so the package's XLIFF export/import and pseudo-localization work on them unchanged. SheetForge adds no second implementation.
- Boundary: output those tools write into the tables counts as an external edit (first entry above). Keep the sheet the source of truth and carry translations through the translation workbook.
No language-specific Smart Format helpers ship
- What: the
smartcolumn marks an entry as a Smart String, but SheetForge provides no grammar formatters of its own — Korean particle selection, for example, is not included, deliberately. - Workaround: the package's Smart Format extension points remain fully available for formatters you write.
Key constants are ASCII-sanitized
- What: the generated
{Tab}Keysconstants turn every character outside ASCII letters, digits and_into_(collisions get a numeric suffix), so a non-ASCII key yields an unreadable constant name — the key itself still works everywhere. - Workaround: keep keys ASCII (
ui.ok,dialog.intro) if you use the constants. Like the sheet-defined enum file, the constants file always generates into the settings folder — the same assembly note applies.
The web app authors localization sheets; the sync is the editor's
- What: authoring, validation, coverage, minting, the locale lens and the translation workbook all work in the browser. Writing StringTables does not — the browser has no Unity project to write into.
- Why: honest scope, not a missing feature: the tables live in the project.
Related pages
- FAQ & Troubleshooting — the symptom-first view of many of these entries
- Sources, Export & Push — the Google/xlsx boundaries in context
- Data Studio — the authoring boundaries in context