SDK version: Unity plugin v2026.7
Impact: unbounded memory growth in any title that lets players browse mods
Severity: measured 190 MB retained after a single Workshop visit, never reclaimed
The problem
Modio/Images/BaseImageCache.cs:
readonly Dictionary<ImageReference, (Error, T)> _cache = new Dictionary<ImageReference, (Error, T)>();
It is written in two places (DownloadImageInternal, LoadFromDiskCache) and read in two. There is
no Remove, no Clear, no capacity bound, no WeakReference, no Dispose anywhere in the type
or its subclasses. Nothing evicts on scene change, on leaving the browser, or under memory pressure.
Notably, every other cache in the SDK does clean up — Caching/BaseCache.cs and
Caching/ModCache.cs both subscribe to ModioClient.OnShutdown. BaseImageCache does not. That
looks like an oversight rather than a decision.
Two things compound it:
-
The textures are created readable. Unity/ImageCacheTexture2D.cs:
var texture = new Texture2D(0, 0);
bool success = texture.LoadImage(rawBytes, false); // markNonReadable: false
So every image costs twice its decoded size — a CPU pixel copy plus the GPU copy — even though
nothing reads the pixels back except the optional disk-cache path.
-
ModContent_ModImageGallery.prefab ships with ModioUIModGallery._resolution set to
GalleryResolution.Original, so the details gallery downloads full-size uploads rather than the
1280x720 thumbnail. The component's own C# default is X1280_Y720; the shipped prefab overrides
it upward. Per-image cost then scales with whatever creators upload — a 4K screenshot costs a
viewer ~63 MB, permanently.
Measured
Unity 6 (6000.3.11f1), editor, real authenticated session. Census over runtime-created Texture2Ds
via Resources.FindObjectsOfTypeAll<Texture2D>(), sized with Profiler.GetRuntimeMemorySizeLong:
| stage |
textures |
retained |
| browser never opened |
0 |
0 MB |
| browser landing screen, no scrolling |
53 |
87.9 MB |
| + one mod's 10 screenshots viewed |
63 |
190.0 MB |
browser closed, GC.Collect() + Resources.UnloadUnusedAssets() |
63 |
190.0 MB, unchanged |
GetRuntimeMemorySizeLong reports exactly 2× the raw w × h × bpp for each, which is the readable
double-cost above. mipmapCount is 1, so this is not mip overhead.
Breakdown of the 87.9 MB landing screen: 43 row thumbnails at 320×180 = 18.4 MB, and 10 featured
tiles at 1280×720 = 69.4 MB. The 102 MB from ten screenshots is the Original gallery setting.
Reproduction
- Open the mod browser.
- Open one mod's details and page through its screenshots.
- Close the browser, force a GC and
Resources.UnloadUnusedAssets().
- Enumerate
Texture2Ds — every image is still resident, and stays resident until the process exits.
Suggested fix
The minimal, source-compatible change is an eviction entry point. Modio.asmdef sets
noEngineReferences: true, so the base assembly cannot destroy a Texture2D itself — returning the
images for the caller to release keeps that boundary intact:
// in BaseImageCache<T>
public List<T> ClearCachedImages()
{
var clearedImages = new List<T>(_cache.Count);
foreach ((Error, T) cached in _cache.Values)
{
if (cached.Item2 != null)
clearedImages.Add(cached.Item2);
}
_cache.Clear();
return clearedImages;
}
// in ImageCacheTexture2D, or wherever the host application drives it
foreach (Texture2D texture in ImageCacheTexture2D.Instance.ClearCachedImages())
UnityEngine.Object.Destroy(texture);
Removing the dictionary entries is the load-bearing half, not the destroy. T is unconstrained, so
every image != null in GetFirstCachedImage and in LazyImage is a plain reference comparison,
not UnityEngine.Object's overload — a texture destroyed while still mapped would pass those guards
and be assigned onto a live RawImage. Any eviction you add must remove the entry.
Three things we found that a fix should be careful about:
- Don't clear
_ongoingDownloads. Dropping an in-flight entry lets the next DownloadImage start
a second download for the same URL; both completions write _cache[uri] and one texture is orphaned
with nothing left holding it.
- Don't clear
PendingDiskSaves. ModioImageSource latches _isCachingLowestResolution, so a
dropped entry is never re-queued and that mod loses its offline logo for the session. Clearing
_cache alone merely defers the save: CacheToDiskInternal returns false, the reference goes
back into PendingDiskSaves, and the next download redeems it.
- A time- or count-based LRU would be unsafe as-is, because the cache is never told when a
consumer stops displaying an image — there is no release, no ref count, and none of the UI
components tear down. Eviction cannot currently know whether the texture it is dropping is on
screen. An explicit entry point that the host application calls at a known-safe moment (we use
LoadSceneMode.Single) is the safe shape until a release API exists.
Also worth considering: markNonReadable: true in ImageCacheTexture2D.Convert would halve the cost
outright. The blocker is ConvertToBytes → Texture2D.EncodeToPNG(), which needs a readable texture.
That path is only reached when CacheToDisk is called for an image already in memory — the download
path already writes the original bytes straight to disk. Keeping the source bytes for the
disk-cache candidates (or re-fetching them) would let every texture be non-readable.
And the shipped ModContent_ModImageGallery.prefab should arguably default to X1280_Y720 to match
the component's own C# default, rather than Original.
Unrelated bug found while investigating
DownloadImageInternal removes from _ongoingDownloads on its success and error paths, but has no
try/finally. If ModioClient.Api.DownloadFile, stream.CopyToAsync or Convert throws — a
network drop mid-transfer is the realistic case — the faulted Task stays in _ongoingDownloads
forever, and every later DownloadImage for that URL returns the same faulted task, rethrowing into
LazyImage.SetImage's async void. That image can never load again for the rest of the session. A
try/finally around the removal would fix it.
Relatedly, neither the Stream from DownloadFile nor the MemoryStream is disposed.
SDK version: Unity plugin v2026.7
Impact: unbounded memory growth in any title that lets players browse mods
Severity: measured 190 MB retained after a single Workshop visit, never reclaimed
The problem
Modio/Images/BaseImageCache.cs:It is written in two places (
DownloadImageInternal,LoadFromDiskCache) and read in two. There isno
Remove, noClear, no capacity bound, noWeakReference, noDisposeanywhere in the typeor its subclasses. Nothing evicts on scene change, on leaving the browser, or under memory pressure.
Notably, every other cache in the SDK does clean up —
Caching/BaseCache.csandCaching/ModCache.csboth subscribe toModioClient.OnShutdown.BaseImageCachedoes not. Thatlooks like an oversight rather than a decision.
Two things compound it:
The textures are created readable.
Unity/ImageCacheTexture2D.cs:So every image costs twice its decoded size — a CPU pixel copy plus the GPU copy — even though
nothing reads the pixels back except the optional disk-cache path.
ModContent_ModImageGallery.prefabships withModioUIModGallery._resolutionset toGalleryResolution.Original, so the details gallery downloads full-size uploads rather than the1280x720thumbnail. The component's own C# default isX1280_Y720; the shipped prefab overridesit upward. Per-image cost then scales with whatever creators upload — a 4K screenshot costs a
viewer ~63 MB, permanently.
Measured
Unity 6 (6000.3.11f1), editor, real authenticated session. Census over runtime-created
Texture2Dsvia
Resources.FindObjectsOfTypeAll<Texture2D>(), sized withProfiler.GetRuntimeMemorySizeLong:GC.Collect()+Resources.UnloadUnusedAssets()GetRuntimeMemorySizeLongreports exactly 2× the raww × h × bppfor each, which is the readabledouble-cost above.
mipmapCountis 1, so this is not mip overhead.Breakdown of the 87.9 MB landing screen: 43 row thumbnails at 320×180 = 18.4 MB, and 10 featured
tiles at 1280×720 = 69.4 MB. The 102 MB from ten screenshots is the
Originalgallery setting.Reproduction
Resources.UnloadUnusedAssets().Texture2Ds — every image is still resident, and stays resident until the process exits.Suggested fix
The minimal, source-compatible change is an eviction entry point.
Modio.asmdefsetsnoEngineReferences: true, so the base assembly cannot destroy aTexture2Ditself — returning theimages for the caller to release keeps that boundary intact:
Removing the dictionary entries is the load-bearing half, not the destroy.
Tis unconstrained, soevery
image != nullinGetFirstCachedImageand inLazyImageis a plain reference comparison,not
UnityEngine.Object's overload — a texture destroyed while still mapped would pass those guardsand be assigned onto a live
RawImage. Any eviction you add must remove the entry.Three things we found that a fix should be careful about:
_ongoingDownloads. Dropping an in-flight entry lets the nextDownloadImagestarta second download for the same URL; both completions write
_cache[uri]and one texture is orphanedwith nothing left holding it.
PendingDiskSaves.ModioImageSourcelatches_isCachingLowestResolution, so adropped entry is never re-queued and that mod loses its offline logo for the session. Clearing
_cachealone merely defers the save:CacheToDiskInternalreturns false, the reference goesback into
PendingDiskSaves, and the next download redeems it.consumer stops displaying an image — there is no release, no ref count, and none of the UI
components tear down. Eviction cannot currently know whether the texture it is dropping is on
screen. An explicit entry point that the host application calls at a known-safe moment (we use
LoadSceneMode.Single) is the safe shape until a release API exists.Also worth considering:
markNonReadable: trueinImageCacheTexture2D.Convertwould halve the costoutright. The blocker is
ConvertToBytes→Texture2D.EncodeToPNG(), which needs a readable texture.That path is only reached when
CacheToDiskis called for an image already in memory — the downloadpath already writes the original bytes straight to disk. Keeping the source bytes for the
disk-cache candidates (or re-fetching them) would let every texture be non-readable.
And the shipped
ModContent_ModImageGallery.prefabshould arguably default toX1280_Y720to matchthe component's own C# default, rather than
Original.Unrelated bug found while investigating
DownloadImageInternalremoves from_ongoingDownloadson its success and error paths, but has notry/finally. IfModioClient.Api.DownloadFile,stream.CopyToAsyncorConvertthrows — anetwork drop mid-transfer is the realistic case — the faulted
Taskstays in_ongoingDownloadsforever, and every later
DownloadImagefor that URL returns the same faulted task, rethrowing intoLazyImage.SetImage'sasync void. That image can never load again for the rest of the session. Atry/finallyaround the removal would fix it.Relatedly, neither the
StreamfromDownloadFilenor theMemoryStreamis disposed.