Build Log

Editing ReactiveEntitySet data at scale in the RES Table View

  • Unity
  • Reactive SO
  • Editor Tooling
  • UI Toolkit
  • Serialization
  • Game Development

TinyHistoryDemo, the nation-simulation sample for Reactive SO, can run up to MAX_ARMIES = 20000 armies at once.

“Why is this one army stuck outside its target province” is not a question the Inspector can answer at that count — selecting a ReactiveEntitySetSO asset only ever showed configuration fields, and the per-entity data lived in a NativeSlice you couldn’t page through by hand.

This post is about RES Table View, the spreadsheet-style editor window I built instead: a MultiColumnListView over entity data, pause-time cell editing that writes back through the same API a running game would use, and a binary .resdata format for saving and restoring a captured snapshot.

TinyHistoryDemo, the sample whose 20,000 armies this table view is built to page through, was introduced in the Reactive SO 2.1 release. Here’s the release video for that version:

Unity Asset StoreReactive SO | Game ToolkitsScriptableObject-based reactive architecture for Unity. Variables, Event Channels, Runtime Sets, GPU Sync, Reactive Entity Sets, and dedicated debugging windows.

RES Table View shipped in 2.1.0, in the same release as TinyHistoryDemo itself, and has been stable since. This is a walkthrough of how it works and the design calls behind it, not a pitch — the companion piece to wiring the same entity sets into Burst Jobs.

Why the Inspector stopped being enough

ReactiveEntitySetSO<TData> used to show its registered entities directly in the Inspector, the same way any List<T> field would.

That doesn’t scale to 20,000 armies — the sibling RuntimeSetSO Inspector hit the same wall and, rather than trying to make an expanded list of thousands of entries render fast, I collapsed it by default and added a warning on expand instead: “Expanding may cause performance issues with large item counts.”

For ReactiveEntitySetSO, I went further and removed the entity list from the Inspector entirely in 2.1.0 — the Inspector now only shows configuration (description, debug toggles, event channel references), and an “Open Table View” button replaces the list.

That only works if the replacement can actually do what the list was for: scan every entity, spot the one with the wrong State or the Health that’s stuck at zero, and — this was the part I wanted most — change it right there and watch the game react, instead of writing a one-off debug script every time.

Pause, capture, edit — not live

RES Table View only works while Play Mode is paused. Nothing in the window updates on its own; you press “Capture Snapshot” to pull a copy of the entity data into the window, and that copy sits there, static, until you capture again or resume the game.

flowchart LR
    A["Not playing"] -->|"Enter Play"| B["Playing"]
    B -->|"Pause"| C["Paused"]
    C -->|"Capture"| D["Captured"]
    D -->|"edit + commit"| D
    D -->|"Resume: clears data"| B
    B -->|"Exit Play"| A

Capture and cell editing are disabled outside the PlayingPausedCaptured path — CaptureSnapshot() bails out with a warning unless both Application.isPlaying and EditorApplication.isPaused are true.

LoadFromFile() only checks EditorApplication.isPaused in the method itself (the Load button’s enabled state in the toolbar gates on both, same as Capture, but the guard inside the method is narrower than the button that triggers it). Resuming (PauseState.Unpaused) clears the captured data immediately either way, rather than letting it go stale next to a running simulation.

I tried a live view first — a window that kept redrawing the table every frame instead of waiting for a manual capture — and abandoned it.

ReactiveEntitySetSO<TData>’s data can be updated from inside a Burst Job while the frame is in flight, and enumerating a NativeContainer while a Job that might still be writing to it is running isn’t safe: Unity’s Job System has a safety system specifically because reading a NativeContainer that a scheduled Job might still be writing to is a race, the same constraint I ran into building the Job System orchestrator for the same entity sets.

A table redrawing every frame off a NativeSlice I don’t control the scheduling of was exactly that kind of concurrent read. Capturing only while paused sidesteps the problem entirely — nothing is running, so there’s nothing to race against.

Reflection-driven columns

The window doesn’t know TData at compile time — it’s generic per ReactiveEntitySetSO<TData> subclass, and the window has to work for all of them.

It resolves the concrete TData by walking up the selected asset’s type hierarchy until it finds a closed ReactiveEntitySetSO<> and reading off that generic argument — reflection standing in for the type parameter an editor window can’t otherwise know.

From there, IsDisplayableType decides which fields become columns: primitives, enum, string, Vector2/Vector3/Vector4, Quaternion, Color/Color32, and Unity.Mathematics types whose name starts with float, int, bool, or double (matching against type.Namespace == "Unity.Mathematics" rather than a fixed type list, so it doesn’t need updating if Unity adds more float*/int* variants).

A field whose type isn’t on that list, and isn’t itself a nested value-type struct, gets skipped and flips hasUnsupportedFields, which the status bar reports as “(some fields skipped)”.

Nested structs get their own pass. A non-primitive value-type field that isn’t already displayable is recursed into, so a Position field of struct type produces Position.X, Position.Y, Position.Z columns instead of one unreadable column — flattened up to 5 levels deep, past which the recursion stops and the field is treated as unsupported rather than walked further.

Reference-type fields are never recursed into, so a class field can’t drag a cyclic reference graph into the flattening.

Nested-struct columns are read-only; the code that resolves a field path down to its leaf value doesn’t have a matching path back to set one field inside a struct field without touching the others, so editing was scoped to top-level fields only.

Editing a cell

Double-clicking a top-level field’s cell (nested-struct columns don’t respond to this) swaps its label for an inline editor picked by field type: EnumField for enums, Toggle for bool, TextField for everything else. The three don’t behave identically on commit.

The text editor listens for Return/KeypadEnter to commit and Escape to cancel, and also commits on FocusOutEvent — click away and the value you typed sticks. The enum and bool editors commit immediately on ValueChangedCallback, since there’s no intermediate text state to abandon; there’s no Escape path for those two, because there’s nothing uncommitted to discard.

A commit calls SetData(entityId, data) on the selected ReactiveEntitySetSO through reflection — the same public method a running simulation would call, which means OnDataChanged fires normally and anything else subscribed to that entity (a view, a UI binding, another system) reacts exactly as it would to any other write.

The window isn’t a special back door into the entity set’s storage; it’s a client of the same API.

The .resdata snapshot format

Save writes the currently captured snapshot to a .resdata file; Load reads one back and calls RestoreSnapshot(NativeArray<TData>, NativeArray<int>, int) to replace the entity set’s contents wholesale. The binary layout is documented in the userguide:

[Header] - 20 bytes
├─ Magic: "RES\0" (4 bytes)
├─ Version: int32
├─ TypeHash: int32 (hash of TData type)
├─ DataSize: int32 (size of TData struct)
└─ EntityCount: int32

[Data]
├─ EntityIds: int32[] (EntityCount items)
└─ EntityData: byte[] (EntityCount × DataSize bytes)

DataSize comes from UnsafeUtility.SizeOf<TData>(), and each entity’s struct bytes are written with Marshal.Copy off a pinned handle — the same reason TData has to be unmanaged everywhere else in RES applies here too, since a struct with managed references has no fixed byte layout to copy.

TypeHash is dataType.AssemblyQualifiedName.GetHashCode(); loading a file whose hash doesn’t match the currently selected asset’s TData logs a warning but doesn’t block the load, on the theory that a rename or namespace move shouldn’t lock you out of your own saved data, only warn you that the shapes might not line up.

The userguide lists this format under Save/Load because it’s meant to be read: a saved .resdata is how you park a specific bug’s entity state, hand it to a regression test as a baseline, or restore a broken run without re-simulating up to it.

Scaling past a handful of entities

The list itself is a MultiColumnListView, not a hand-rolled loop over rows — Unity’s own MultiColumnListView documentation covers its virtualizationMethod, which is what keeps only the visible rows instantiated regardless of how many entities are captured.

The published spec for the window, in the 2.1.0 CHANGELOG entry and the userguide’s Limitations table, states it supports 10,000+ entities through that virtualization, with the caveat that initial capture — the reflection-driven copy out of the entity set, done once per “Capture Snapshot” click — may take time at that size.

I haven’t run a benchmark of my own against that number; I’m citing the shipped spec rather than a measurement.

Things still bugging me

The TypeHash scheme is exactly as fragile as GetHashCode() on an AssemblyQualifiedName sounds — rename the struct, move it to a different namespace, or change the assembly it lives in, and old .resdata files start logging a mismatch warning even though nothing about the data actually changed.

It doesn’t block loading, so it’s a nuisance rather than data loss, but I’d rather it not warn at all for a pure rename.

There’s no diff view between two .resdata files. Comparing “before” and “after” snapshots right now means opening each one in the window separately and eyeballing the columns, which is exactly the kind of manual scan the table view was supposed to replace for live data.

Sort and filter on columns would make scanning thousands of rows for the one outlier faster than it currently is, and MultiColumnListView’s Column type doesn’t get either from me automatically — I’d have to wire it up. I haven’t.

Wrap-up

The userguide still opens the RES Table View page with the same warning the rest of the Reactive Entity Set system carries: ReactiveEntitySetSO is experimental, and the API may change in future versions.

That covers the table window too — SetData reflection lookups, the .resdata layout, the RestoreSnapshot signature it calls — all of it sits on top of a surface I’ve reserved the right to still adjust.

Nothing about that has bitten me since 2.1.0 shipped, but it’s the honest state of things: this is a debugging tool built on top of a part of the library that isn’t done settling.

References