Sources, Export & Push
Three write paths exist, each with a different target:
- reflect writes authoring staging to the source.
- Export writes baked SO values back into sheet files.
- Push writes baked SO values to the live Google sheet cell by cell.
Import sources
The import source is a first-class choice in the settings asset. Each source declares its own authoring capability (CanAuthor):
| Source | What it reads | Authoring (write-back) |
|---|---|---|
| LocalFile | A folder of .tsv / .csv / .xlsx files (immediate children only; one file = one tab, xlsx workbooks contribute their sheets) | Full — reflect, structure edit, key/tab rename |
| GoogleSheet · SheetsApi | A private/shared spreadsheet via service-account JWT auth (setup guide) | Full — surgical cell writes, structure rewrite, Push |
| GoogleSheet · ExportUrl | A link-shared sheet via its export URL — no auth needed | Read-only (CanAuthor = false) — Push/reflect/structure-edit/delete are disabled with an explanation |
| Custom providers | Anything a plugin registers (ISheetSourceProvider — DB, REST, in-house formats) | Provider's choice via its CanAuthor flag |
Notes:
- ExportUrl requires a gid map (tab name →
#gid=value). A gid-less export URL silently returns only the first tab, so the map is enforced (GoogleSheetGidMapMissing, duplicate gids rejected). SheetsApi mode discovers tabs automatically and needs no map. - The built-in xlsx reader/writer is hand-written OOXML (
System.IO.Compression+System.Xmlonly — no NPOI/ClosedXML, zero third-party code), so it adds no DLLs that can collide with other assets in your project. It is one codec, shared: the same reader runs in the Unity editor and — compiled to WebAssembly — in the web app, so the two hosts cannot disagree about a cell. It is intentionally minimal and honest about it — values only, no recalculation:- A formula cell contributes the value cached in the file. A formula with no cached value, and an error cell (
#REF!,#DIV/0!), are rejected (UnsupportedXlsxCell) — save the workbook once in Excel to cache values, or materialize formulas. - A date-formatted cell is read as its date, rendered
yyyy-MM-dd— both the ISO cell type and a plain number whose style is a date format, with the 1900 and 1904 date systems both honoured — instead of the raw serial number the file stores. Other number formats, merged cells and charts are not imported. - Those interpretations — cached formula values, dates as display text, formatting ignored — are the reader's fixed policy in both hosts, and the web app's import dialog additionally names the ones that actually happened in a "How this workbook was read" note.
- A tab or newline inside a cell is rejected (
UnsupportedCellCharacter) — use;for lists. - Cell-type codes the reader doesn't recognize are read as their raw stored text, not rejected.
- A formula cell contributes the value cached in the file. A formula with no cached value, and an error cell (
- Local files must be Unicode. A UTF-8 BOM or a UTF-16 BOM (LE or BE) is honoured; without a BOM the file is decoded as strict UTF-8. A legacy single-byte encoding such as CP949 or Shift-JIS is rejected (
UnsupportedEncoding), not guessed at. A guess would decode differently on different machines and silently corrupt the data. Re-save the file as UTF-8. - A source can return partial output — one broken file doesn't drop the readable tabs; the problems arrive as diagnostics.
- Custom source providers are discovered automatically and appear in the same settings dropdown — see Plugin Authoring.
- The studio tells you when the source moved on without you. On window focus — or on demand from the ⋯ menu — the studio re-reads the source and compares it against your last import's snapshot, and shows a badge only when the data actually differs: a re-saved or merely re-formatted sheet stays quiet, because the comparison is on content, not timestamps. Clicking the badge offers to run the import; nothing polls on a timer, nothing imports by itself, and being offline or unauthorized simply means no badge. It works for every source kind the same way — local files, export-URL sheets and the Sheets API alike.
Export — the round-trip's return half
⋯ ▸ Run Export in the Data Studio toolbar writes baked SO values back into sheet files.
- Structure comes from the baseline, values from the SOs. Export swaps the current values into the baseline snapshot of your sheet's structure. Marker rows, column order, comments, and human-written text are preserved 100%.
- Semantic value round-trip:
1.0↔1normalization is allowed (identical value); floats use the shortest round-trip format; always.decimal. - Formats:
Tsv/Csv/Xlsx/Json/MatchSource— each tab returns to the format it was imported from; Google-origin or unknown falls back to Tsv.Jsonis an outbound-only format for machines rather than spreadsheets: one file per tab, records as objects,int/float/boolas real JSON numbers and booleans, and every other value — references, lists, colours, curves, custom types — in the exact canonical cell text the sheet holds, so a server or external tool can consume game data without parsing sheet text. JSON is not an import source, and a JSON file carries no sheet structure to round-trip — the sheet stays canonical. TSV and CSV write one file per tab;Xlsxwrites every exported tab into one workbook (SheetForge.xlsx), each tab as its own sheet in tab order — a workbook is the format made to hold several sheets, and keeping them together is also what lets reference dropdowns point across sheets (below). UnderMatchSource, the xlsx-origin tabs gather into that one workbook while the others return to their own files. A sheet name the workbook rules cannot hold (too long, or a forbidden character) is adjusted and reported by name — never silently renamed. - Freshness is enforced: exporting with a stale bake after a schema change fails with
ExportSchemaMismatch. The bakedSchemaFingerprintmust match the baseline's, so run an import first. - Asset references export back as the address text the sheet uses — the key, or
parent[sub]for a sub-asset; the group is the column's — never as GUIDs. Typed columns (AssetRef@Group<Type>) round-trip the same way.Color,AnimationCurveandGradientvalues come back in their canonical text form (see Sheet Syntax); a curve with no keys exports as an empty cell, and a colour is clamped to 0…1 (no HDR).
Push — cell-level write-back to Google Sheets
⋯ ▸ Push to Google Sheet in the Data Studio toolbar sends baked SO values to the live sheet, cell by cell. The item is disabled, with the reason spelled out, unless the active source is Google Sheets in API mode. It is designed so that it never corrupts a live sheet someone else is editing.
Three guarantees follow from the chain:
- Nothing is sent without your approval of a cell-level plan.
- A cell that changed on the live sheet after your import is skipped, never overwritten.
- A row deletion goes out only when the live sheet still shows that key on that exact row — anything that drifted is skipped with a notice, never guessed at.
The safety chain, in order:
- SheetsApi credentials required — Push in ExportUrl mode is refused before any network call (
GooglePushRequiresSheetsApi). - A key column is required per pushed tab — Push re-locates each row by key in the live sheet. That is how it detects a row that moved and skips that write safely, never sending it to the wrong row. A keyless tab with changes is refused (
PushKeylessTabUnsupported). - Plan + approval — a cell-level diff (baseline vs current SO) is computed as a plan: writes, appends, row deletions. The plan is shown for explicit approval before anything is sent; deletions stand in their own section, each named by the key that will disappear. Reject = zero cells sent.
- Pre-send live re-fetch: immediately before sending, the live sheet is re-fetched and compared. Conflicting cells are skipped, not overwritten (reported as warnings):
PushConflictCellChanged— a third party edited that cell.PushConflictRowMoved— the key was found at a different row than your import saw, so the write is skipped (never sent to the wrong row). Re-import to re-sync, then Push again.PushConflictRowMissing— the row was deleted externally.PushConflictDuplicateLiveKey/PushConflictAppendKeyExists— ambiguous targets.
- Row deletions are matched by key before they are sent. A record you deleted is removed from the live sheet only after the pre-send fetch confirms its key still sits on the exact row your import saw: a row that is already gone counts as done (a re-push deletes nothing twice), and a key found on a different row — the sheet drifted — is skipped with a notice, never deleted by position. Deletions go out last, bottom-up inside each tab, so earlier removals cannot shift the coordinates of later ones. A source that cannot delete rows (a custom provider without the capability) falls back to the old behaviour honestly: the deletion is reported and the live row is left for you.
After a Push, check the report's applied/skipped counts. If cells were skipped, re-import to reconcile and Push again.
Structure changes to Google
Structure edits (columns, markers, reorder, renames) on a Google source rewrite the whole target tab. A live-diff check comes first, and explicit approval is required before overwriting anything that changed on the sheet after your last import. Value edits stay surgical (per-cell); only structure uses the rewrite path.
Addressables registrations made by a reflect
Dropping an asset onto an AssetRef@Group cell in the Data Studio, or picking one from the project, may stage a change to the project as well as to the sheet: adding the asset to the group, moving it from another group, or creating the group. Those registrations are part of the reflect and run in a fixed place in the chain — the same place for a local folder, a Google sheet and a custom source provider:
- Pre-flight validates the whole projected state with the staged registrations counted as present, so a cell that points at a not-yet-registered asset is not an error.
- The sheet is written. If the write is cancelled or fails, nothing below runs: the Addressables settings are untouched and the registrations stay staged for the next attempt. A reflect that could not write any tab because every touched tab was skipped (for example when only workbook-backed tabs were touched) does not run them either. A reflect that has nothing to write to the sheet at all — the only staged change is a registration — does run them and re-imports; no other staged edit is committed by that pass, so it stays undoable.
- The registrations run, in order: groups are created first (with the default
BundledAssetGroupSchemaandContentUpdateGroupSchema), then entries are added or moved and given their address, and the settings are saved once. Each item is re-checked immediately before it runs and is skipped rather than forced when the asset has since been deleted, when the address is now taken by a different asset in that group, when the group could not be created or found, and when no cell references the address any more (a registration never creates an entry nothing points at, and a group whose every entry was skipped is not created either). If the project has no Addressables settings asset yet, one is created for the purpose. - The staged list is cleared — applied and skipped alike — and the automatic re-import follows, so the bake sees the new entries. A skipped registration is therefore reported honestly at that re-import as
UnknownAssetKeyon the cell that needed it.
The Console carries one line per outcome — Addressables: 'address' → group 'Group' for each applied item, Addressables: skipped 'address' (reason) as a warning for each skipped one — and a summary line Addressables: N registered, M skipped. For a local folder source the reflect's completion dialog ends with that same summary line.
Dropdowns written into the sheet
Columns whose choices are finite get a data-validation rule attached to the sheet, so the person editing in Google Sheets or Excel picks from a list instead of remembering spellings. Nothing has to be turned on: the rules are computed on every Export, Push and authoring write-back, and applied wherever the target can carry them.
| Column | Rule |
|---|---|
Enum<T> scalar | A fixed list of that enum's members. |
Reference scalar (RecordId@Tab, and a custom type with reference parity — §4.4a) | A range over the target tab's key column, left open-ended, so records added to the target tab join the list on their own. |
List<>, wrapper columns, the key column itself | No rule — one cell holds several values there, or there is no target to list. |
- Guidance, never enforcement. Every rule is non-strict (Google
strict:false, xlsxshowErrorMessage="0"): a value outside the list is flagged with a warning marker but still accepted. Hard rejection would break the ordinary "write the reference now, define the record later" workflow. It would also fight the import's own nearest-match suggestions. - The rules are display metadata, not values. They never appear in a cell, so the round-trip is unaffected. An export with no rules is byte-identical to one produced before this existed.
- Applied independently of the values. Attaching rules is its own step rather than a side effect of writing cells. The most common flow — add an enum member, change no data — sends zero cells, so a side effect would never run. Attaching is idempotent, so re-running it changes nothing.
- A failure is a warning, not a failed push. If the values went out and only the rules could not be attached, the push still succeeded; run it again and only the rules are re-applied.
What each format can carry:
| Target | Mechanism | Notes |
|---|---|---|
| Google Sheets (Push / write-back) | setDataValidation, batched into one request | Both rule kinds. The reference range omits its end row, so it follows the target tab as it grows. |
| xlsx (Export) | dataValidations after the sheet data | Both rule kinds. Because the export is one workbook, a reference range points at the target sheet's key column inside the same file, open-ended down the sheet — the same meaning the Google range has. A rule is still skipped, and named in the warning, in three honest cases: a list member containing a comma (the inline separator would split it), an inline list over the 255-character spec limit (the whole quoted list is what the format caps), and a range whose target tab is not in the workbook. |
| TSV / CSV (Export) | — | Plain text has nowhere to put them. |
| JSON (Export) | — | A data file, not a spreadsheet — there is no cell to attach a dropdown to. |
Anything left out is reported honestly as a single DropdownNotSupportedByFormat warning per run, naming every affected column. The answer to "why are there dropdowns on Google but not in my file?" is therefore in the report rather than a mystery. It is a warning and not an error because the values themselves exported in full; only the editing convenience is missing.
The gid map
Only used in ExportUrl mode. Each entry maps a tab name to the sheet's #gid= value (visible in the browser URL when the tab is selected). The settings inspector shows the map only when it is relevant.
You do not have to copy those numbers out of the browser one at a time. The settings asset's inspector has a Google Sheets section that fills the map for you.
- In ExportUrl mode, Autofill gid from live reads the live spreadsheet's tab list and rewrites the whole map from it, then saves the settings asset.
- In SheetsApi mode the same panel instead offers Fetch live tab list, which just shows you the tabs the sheet currently has. That mode discovers gids on its own and needs no map at all.
One caveat: autofill talks to the Sheets API, so it needs a service-account key configured even though ExportUrl importing itself does not. Without one it stops and says so rather than writing a half-filled map.
Build freshness hook — a stale bake fails the build
Before every build, a pre-build hook verifies three things for each committed generated Database type:
- (i) the baked SO exists;
- (ii) its schema fingerprint matches the baseline;
- (iii) its Addressables registration exists.
Any failure aborts the build with an actionable sentence (e.g. "open Tools/SheetForge/Data Studio, press ↓ Pull from source, then build"). This is what makes "baked SOs are gitignored" safe: a clone or CI machine cannot ship an empty cache.
Related pages
- Getting Started — service-account key setup and security
- Core Concepts — baselines and the round-trip model
- Data Studio — authoring writes vs Push
- Capabilities & Limits — the complete Google/xlsx limits list