Package: com.wallstop-studios.unity-helpers 3.5.1 (PackageCache ec77ef9677b2)
Unity: 6000.5.2f1 (also present since at least 3.x — see "how long this survived")
Summary
A SerializableDictionary<TKey, TValue> whose TValue is itself a collection is write-only under
Unity serialization: the asset records _keys and no _values at all. There is no error, no
warning, and the Inspector keeps accepting entries, so the dictionary looks authored and every
runtime lookup comes back empty.
Unity's serializer refuses a nested collection — a field typed List<T>[], List<List<T>> or
T[][] is not serialized. SerializableDictionaryBase<TKey, TValue, TValueCache> declares
[SerializeField]
protected internal TValueCache[] _values;
and the two-argument convenience type sets TValueCache = TValue:
public class SerializableDictionary<TKey, TValue> : SerializableDictionaryBase<TKey, TValue, TValue>
so SerializableDictionary<string, List<float>> makes that field a List<float>[] and Unity drops
it. _keys is a TKey[], so it survives — which is what makes the failure look like data rather
than a type error.
Evidence (measured, on real assets)
Swept every _keys: block in a production project's Assets/**/*.{asset,prefab,unity}:
total _keys blocks: 53
missing _values: 7 <- all seven are the List<>-valued shape
the other 46 (simple value types) round-trip fine
The decisive contrast is one file, one serializer, two dictionaries:
| Field |
Declared type |
_keys |
_values |
_waitTimesStore |
SerializableDictionary<Vector2, float> |
present |
present |
_eventsStore |
SerializableDictionary<Vector2, List<KEventData>> |
present |
absent |
The only difference is the value type being a List<>.
Note the shape of the absence: a correctly typed empty dictionary writes both halves —
_waitTimesStore:
_keys: []
_values: [] # correct, empty
_eventsStore:
_keys: [] # mis-shaped: no _values line at all
so "no _values sibling" is a reliable detector of the declaration being wrong, independent of
whether anything has been authored yet.
The positive direction is measured too. Before changing the value type,
new SerializedObject(component).FindProperty("_decals._values") returned null — Unity has no
such field. After wrapping the list in a [Serializable] class it returned non-null and the YAML
became:
_decals:
_keys:
- {fileID: 11400000, guid: 8938d774870e045479e820d3dbfded72, type: 2}
_values:
- _items:
- {fileID: 21300000, guid: 46a73363348532645be9383d370c420b, type: 3}
Also worth knowing: this is Unity-specific. _keys/_values carry [ProtoMember] and
[JsonInclude], so the same dictionary can round-trip through the JSON/ProtoBuf paths and still lose
every value in a .prefab or .asset. That asymmetry makes it materially harder to notice.
Why nothing notices — the drawer swallows it
SerializableDictionaryPropertyDrawer resolves both halves and tolerates a missing one:
// GetOrCreateCachedPropertyPair
valuesProperty = dictionaryProperty.FindPropertyRelative(
SerializableDictionarySerializedPropertyNames.Values); // -> null for a nested value type
// EnsureParallelArraySizes
if (keysProperty == null || valuesProperty == null)
{
return; // silently
}
and then continues drawing. So the user gets a working-looking key column and a value column that
persists nothing. The drawer is the one component that can detect this — SerializedProperty is
exactly the thing that knows the field was not serialized — and it is the one place currently
choosing not to.
Second, separable bug: the documented workaround has no drawer
SerializableDictionary<TKey, TValue, TValueCache> is documented as the answer to this class of
problem:
Serializable dictionary that stores value data inside cache objects so complex or
non-serializable runtime types can participate in Unity serialization.
And it does work — [Serializable] sealed class FloatListCache : SerializableDictionary.Cache<List<float>> {}
makes _values a FloatListCache[], which Unity serializes. But the drawer is not registered for it:
[CustomPropertyDrawer(typeof(SerializableDictionary<,>), true)]
[CustomPropertyDrawer(typeof(SerializableSortedDictionary<,>), true)]
[CustomPropertyDrawer(typeof(SerializableSortedDictionary<,,>), true)] // <- sorted cache form: yes
// SerializableDictionary<,,> <- unsorted cache form: missing
useForChildren: true does not cover it, because the three-argument type derives from
SerializableDictionaryBase<TKey, TValue, TValueCache> and not from SerializableDictionary<TKey, TValue>.
The sorted variant is registered, so this reads as an oversight rather than a decision. Net effect:
following the documentation to fix the data costs you the Inspector.
Scope — the same shape, three more types
| Type |
Backing field |
SerializableDictionaryBase<TKey, TValue, TValueCache> |
TValueCache[] _values |
SerializableSortedDictionaryBase<TKey, TValue, TValueCache> |
TValueCache[] _values |
SerializableSetBase<T, TSet> (SerializableHashSet<T>, SerializableSortedSet<T>) |
T[] _items |
A set of lists is a rarer thing to author than a dictionary of lists, but the mechanism is identical,
so a fix aimed at the whole family is worth more than one aimed at SerializableDictionary.
Repro
using System.Collections.Generic;
using UnityEngine;
using WallstopStudios.UnityHelpers.Core.DataStructure.Adapters;
public sealed class NestedValueRepro : MonoBehaviour
{
[SerializeField]
private SerializableDictionary<string, float> _control = new(); // simple value
[SerializeField]
private SerializableDictionary<string, List<float>> _subject = new(); // collection value
}
- Put it on a prefab.
- In the Inspector, add one entry to each dictionary and give both a value.
- Save, then open the
.prefab in a text editor.
_control has _keys and _values. _subject has _keys and no _values key at all.
- Enter Play mode (or reimport) and read both back:
_control has its entry, _subject is empty.
- Optional, and the cleanest single check:
new SerializedObject(component).FindProperty("_subject._values") is null, while
"_control._values" is not.
Honesty note: steps 1–5 are the reduction of what was measured on real project assets (the sweep
and the before/after FindProperty results above). The standalone script itself has not been run —
the editor that produced the measurements became unavailable — so if you would like it confirmed in
this exact form before acting, that is a fair ask.
Suggested fix
Ordered by value-per-risk. (1) alone would have saved the whole investigation.
1. Make it loud in the Inspector — smallest change, biggest win.
In SerializableDictionaryPropertyDrawer, treat "keys resolved, values did not" as a first-class
error rather than a shrug. In both OnGUI and GetPropertyHeight, right after the pair is resolved:
if (keysProperty != null && valuesProperty == null)
{
EditorGUI.HelpBox(
position,
$"'{property.displayName}' cannot be serialized: Unity does not serialize a nested "
+ "collection, so this dictionary's values are dropped and every lookup returns "
+ "nothing. Wrap the value type in a [Serializable] class.",
MessageType.Error);
return; // and return the box's height from GetPropertyHeight
}
The drawer already has an Unsupported type (X) vocabulary (GetUnsupportedTypeContent), so this
fits what is there. Refusing to draw the rows is deliberate: letting someone keep typing into a
column that persists nothing is the actual harm.
2. Register the missing drawer — one attribute, restores the documented escape hatch:
[CustomPropertyDrawer(typeof(SerializableDictionary<,,>), true)]
3. Ship the wrapper, so the fix is a type change and not a new class per element type.
Today the cache route needs a concrete Cache<T> subclass for every value type. A single
general-purpose box removes that:
[Serializable]
public sealed class SerializableList<T>
{
[SerializeField]
private List<T> _items = new();
public IReadOnlyList<T> Items => _items ?? (IReadOnlyList<T>)Array.Empty<T>();
public int Count => _items == null ? 0 : _items.Count;
public SerializableList() { }
public SerializableList(IEnumerable<T> items) =>
_items = items == null ? new List<T>() : new List<T>(items);
}
SerializableDictionary<Prop, SerializableList<Sprite>> then serializes, and keeps the existing
two-argument drawer. Measured: the generic instantiation serializes fine — no per-element-type
subclass needed — which was the one thing worth checking rather than assuming. (This is what we
shipped downstream; the library is the better home for it, since the constraint is the library's.)
4. Say it in the XML docs. SerializableDictionary<TKey, TValue>'s summary should state that
TValue must be Unity-serializable and that a collection is not, with a pointer to (2)/(3).
5. Stretch: catch it at compile time. A C# generic constraint cannot express "not a collection",
but a small Roslyn analyzer over SerializableDictionary<,> / SerializableSet*<> type arguments
could, and would move the failure from asset-authoring time to build time.
How long this survived, for calibration
The project this was found in previously used Odin, which could serialize the nesting. The assets
still carry the correct mapping in their now-dead serializationData blobs, so migrating off Odin
silently changed what the data model could express — four separate declarations went inert at once
and one of them (a prop → decal-sprite mapping) was dead for months with the only symptom being a
visual effect that never appeared. The consumer guarded with values.Count <= 0, which is exactly
the defensive check that turns this into silence.
Filed from downstream work: Ambiguous-Interactive/IshoBoy#263 (analysis) and
Ambiguous-Interactive/IshoBoy#265 (the downstream fix, including the wrapper in (3) and a build gate
asserting no serialized dictionary has keys without values).
Package:
com.wallstop-studios.unity-helpers3.5.1 (PackageCacheec77ef9677b2)Unity: 6000.5.2f1 (also present since at least 3.x — see "how long this survived")
Summary
A
SerializableDictionary<TKey, TValue>whoseTValueis itself a collection is write-only underUnity serialization: the asset records
_keysand no_valuesat all. There is no error, nowarning, and the Inspector keeps accepting entries, so the dictionary looks authored and every
runtime lookup comes back empty.
Unity's serializer refuses a nested collection — a field typed
List<T>[],List<List<T>>orT[][]is not serialized.SerializableDictionaryBase<TKey, TValue, TValueCache>declaresand the two-argument convenience type sets
TValueCache = TValue:so
SerializableDictionary<string, List<float>>makes that field aList<float>[]and Unity dropsit.
_keysis aTKey[], so it survives — which is what makes the failure look like data ratherthan a type error.
Evidence (measured, on real assets)
Swept every
_keys:block in a production project'sAssets/**/*.{asset,prefab,unity}:The decisive contrast is one file, one serializer, two dictionaries:
_keys_values_waitTimesStoreSerializableDictionary<Vector2, float>_eventsStoreSerializableDictionary<Vector2, List<KEventData>>The only difference is the value type being a
List<>.Note the shape of the absence: a correctly typed empty dictionary writes both halves —
so "no
_valuessibling" is a reliable detector of the declaration being wrong, independent ofwhether anything has been authored yet.
The positive direction is measured too. Before changing the value type,
new SerializedObject(component).FindProperty("_decals._values")returned null — Unity has nosuch field. After wrapping the list in a
[Serializable]class it returned non-null and the YAMLbecame:
Also worth knowing: this is Unity-specific.
_keys/_valuescarry[ProtoMember]and[JsonInclude], so the same dictionary can round-trip through the JSON/ProtoBuf paths and still loseevery value in a
.prefabor.asset. That asymmetry makes it materially harder to notice.Why nothing notices — the drawer swallows it
SerializableDictionaryPropertyDrawerresolves both halves and tolerates a missing one:and then continues drawing. So the user gets a working-looking key column and a value column that
persists nothing. The drawer is the one component that can detect this —
SerializedPropertyisexactly the thing that knows the field was not serialized — and it is the one place currently
choosing not to.
Second, separable bug: the documented workaround has no drawer
SerializableDictionary<TKey, TValue, TValueCache>is documented as the answer to this class ofproblem:
And it does work —
[Serializable] sealed class FloatListCache : SerializableDictionary.Cache<List<float>> {}makes
_valuesaFloatListCache[], which Unity serializes. But the drawer is not registered for it:useForChildren: truedoes not cover it, because the three-argument type derives fromSerializableDictionaryBase<TKey, TValue, TValueCache>and not fromSerializableDictionary<TKey, TValue>.The sorted variant is registered, so this reads as an oversight rather than a decision. Net effect:
following the documentation to fix the data costs you the Inspector.
Scope — the same shape, three more types
SerializableDictionaryBase<TKey, TValue, TValueCache>TValueCache[] _valuesSerializableSortedDictionaryBase<TKey, TValue, TValueCache>TValueCache[] _valuesSerializableSetBase<T, TSet>(SerializableHashSet<T>,SerializableSortedSet<T>)T[] _itemsA set of lists is a rarer thing to author than a dictionary of lists, but the mechanism is identical,
so a fix aimed at the whole family is worth more than one aimed at
SerializableDictionary.Repro
.prefabin a text editor._controlhas_keysand_values._subjecthas_keysand no_valueskey at all._controlhas its entry,_subjectis empty.new SerializedObject(component).FindProperty("_subject._values")isnull, while"_control._values"is not.Honesty note: steps 1–5 are the reduction of what was measured on real project assets (the sweep
and the before/after
FindPropertyresults above). The standalone script itself has not been run —the editor that produced the measurements became unavailable — so if you would like it confirmed in
this exact form before acting, that is a fair ask.
Suggested fix
Ordered by value-per-risk. (1) alone would have saved the whole investigation.
1. Make it loud in the Inspector — smallest change, biggest win.
In
SerializableDictionaryPropertyDrawer, treat "keys resolved, values did not" as a first-classerror rather than a shrug. In both
OnGUIandGetPropertyHeight, right after the pair is resolved:The drawer already has an
Unsupported type (X)vocabulary (GetUnsupportedTypeContent), so thisfits what is there. Refusing to draw the rows is deliberate: letting someone keep typing into a
column that persists nothing is the actual harm.
2. Register the missing drawer — one attribute, restores the documented escape hatch:
3. Ship the wrapper, so the fix is a type change and not a new class per element type.
Today the cache route needs a concrete
Cache<T>subclass for every value type. A singlegeneral-purpose box removes that:
SerializableDictionary<Prop, SerializableList<Sprite>>then serializes, and keeps the existingtwo-argument drawer. Measured: the generic instantiation serializes fine — no per-element-type
subclass needed — which was the one thing worth checking rather than assuming. (This is what we
shipped downstream; the library is the better home for it, since the constraint is the library's.)
4. Say it in the XML docs.
SerializableDictionary<TKey, TValue>'s summary should state thatTValuemust be Unity-serializable and that a collection is not, with a pointer to (2)/(3).5. Stretch: catch it at compile time. A C# generic constraint cannot express "not a collection",
but a small Roslyn analyzer over
SerializableDictionary<,>/SerializableSet*<>type argumentscould, and would move the failure from asset-authoring time to build time.
How long this survived, for calibration
The project this was found in previously used Odin, which could serialize the nesting. The assets
still carry the correct mapping in their now-dead
serializationDatablobs, so migrating off Odinsilently changed what the data model could express — four separate declarations went inert at once
and one of them (a prop → decal-sprite mapping) was dead for months with the only symptom being a
visual effect that never appeared. The consumer guarded with
values.Count <= 0, which is exactlythe defensive check that turns this into silence.
Filed from downstream work: Ambiguous-Interactive/IshoBoy#263 (analysis) and
Ambiguous-Interactive/IshoBoy#265 (the downstream fix, including the wrapper in (3) and a build gate
asserting no serialized dictionary has keys without values).