Content toolkit

The store, the mapper seam, the on-disk layout it expects, item diffing, and reference-counted asset caching.

on this page

For apps whose promoted versions carry content, ContentManager<T> delegates the content-specific work to the pieces on this page. Each one is a seam you can replace, but the manager needs an implementation of each.

ContentStore<T>

Resolves a version folder on disk and its configured source directories, lists files, and hands them to an IContentMapper<T>. It is fully shape-agnostic; all parsing, joins, and item ordering live in the mapper.

csharp
public ContentStore(
    string contentRoot,
    IReadOnlyList<string> sourceFolders,
    IContentMapper<T> mapper)

public Task<LoadedVersion<T>> LoadVersionAsync(string versionId, CancellationToken ct);

It implements IContentStore<T>, the one-method seam ContentManager depends on, so you can substitute your own for a CMS that is not file-based:

csharp
public interface IContentStore<T>
{
    Task<LoadedVersion<T>> LoadVersionAsync(string versionId, CancellationToken ct);
}

LoadedVersion<T> is the plain result type any implementation has to hand back — three public fields:

FieldType
VersionIdstring
VersionFolderstring
ItemsIReadOnlyList<ContentItem<T>>

The on-disk layout it expects

Exactly the layout the Launchpad downloader writes:

plaintext
contentRoot/
  manifest.json          declares the current versionId
  versions/
    <versionId>/         folder name IS the version id
      <sourceFolder>/    one per configured sourceFolders entry

A version folder may sit directly under contentRoot instead of under versions/. Beyond that, resolution is strict: nothing is matched by substring and no directory tree is crawled.

  • A promoted versionId resolves to the folder of exactly that name, or the load fails.
  • A cold-boot load (versionId: null) takes the version manifest.json declares, falling back to the newest folder that has all configured source folders.

IContentMapper<T>

The seam you implement per project or per CMS. It owns parsing in any format, cross-file and cross-source joins, and the final item order.

csharp
public interface IContentMapper<T>
{
    IReadOnlyList<ContentItem<T>> Map(ContentSourceContext context);
}

ContentSourceContext has three public fields:

FieldType
VersionIdstring
VersionFolderstring
SourcesIReadOnlyList<ContentSourceFolder>

Each ContentSourceFolder exposes public IReadOnlyList<string> Files — every file under that source folder, recursive, ordinal-sorted, with *.original.json excluded.

ContentItem<T>

What a mapper emits. Three public fields:

FieldTypeNotes
IdstringMust be unique and non-empty across the whole list
ContentHashulongWhat the differ compares
DataTYour model

ContentJsonFiles

A helper for the common “content is plain JSON files” case:

csharp
public static IEnumerable<JToken> ParseItems(IEnumerable<string> files);

It accepts either a bare array or a {"data":[...]} envelope. A JSON object that is neither — a global-config file, say — is deliberately skipped, since there is no array of elements to find. Read those directly instead; see singleton models.

ContentHashing

csharp
public static ulong Hash(string canonicalJson);
public static ulong Hash(JToken value, params string[] excludeTopLevelFields);

Use excludeTopLevelFields to drop volatile fields — export timestamps and the like — so a re-export with no real change does not hash as changed.

Diffing

csharp
public static ContentDiff<T> Diff<T>(
    IReadOnlyList<ContentItem<T>> oldItems,
    IReadOnlyList<ContentItem<T>> newItems);

ContentDiffer.Diff compares by Id plus ContentHash, one item at a time, and throws on a duplicate or empty Id anywhere in the list.

ContentDiff<T>:

MemberType
AddedList<ContentItem<T>>
ChangedList<ContentItem<T>>
UnchangedList<ContentItem<T>>
RemovedIdsList<string>
OrderChangedbool
IsEmptybool

IsEmpty is what ContentManager reads to decide a swap needs no gate — see when a staged version commits.

Asset caching

IAssetCache

A format-agnostic load and refcount seam:

csharp
public interface IAssetCache
{
    Task<bool> RetainAsync(string absolutePath, CancellationToken ct);
    void Release(string absolutePath);
    void EvictUnreferenced();
}

A single RetainAsync both loads the asset and claims one reference to it, so there is no window where an asset sits cached at zero references waiting to be claimed. Release gives the reference back, and EvictUnreferenced frees whatever that made unreferenced.

Retaining does not hand you anything to render. That is a separate, type-specific read method beyond the seam itself.

TextureCache

The built-in Texture2D implementation.

csharp
public int Count { get; }

public Task<bool> RetainAsync(string absolutePath, CancellationToken ct);
public Task<Texture2D> GetAsync(string absolutePath, CancellationToken ct);
public void Release(string absolutePath);
public void EvictUnreferenced();
public void Dispose();

GetAsync is the type-specific read method — how a view actually gets the Texture2D to display, whether or not anything retained it yet. It loads and caches on demand either way. It is main-thread only.

Concurrent callers for the same path coalesce onto one shared load rather than decoding the same file twice. Each caller’s own ct cancels only that caller’s wait on the result; it never aborts the shared load out from under whoever else is coalesced onto it.

After Dispose, TextureCache is inert rather than throwing.

Supply your own IAssetCache for audio, video, or other asset kinds, with your own equivalent read method.

AssetLease

The whole set of references one content version holds, as a single IDisposable.

csharp
public IReadOnlyCollection<string> RetainedPaths { get; }

public static Task<AssetLease> AcquireAsync(
    IAssetCache cache,
    IEnumerable<string> paths,
    CancellationToken ct);

public void Dispose();

There is no public constructor — AcquireAsync is the only way to get one. It loads with bounded concurrency, skips assets that fail to load, and unwinds itself if the acquire is cancelled or throws.

ContentManager hands a lease from staged to current and disposes the outgoing one. That is the only place refcount bookkeeping happens, which is why you do not call Retain/Release yourself in the normal flow.