← All projects

wsteam

A C# exploration of Steam's depot and Workshop content pipeline. I built a cross-platform CLI that parses manifests and downloads chunks in parallel, reaching about 70 MB/s in my tests instead of 2-3 MB/s.

Personal project C# .NET 10 SteamKit2 Spectre.Console

The problem

Steam downloads content as chunks - individually addressable pieces of a depot, each with its own offset, CRC, and encryption key. Steam's own client downloads them sequentially on one connection, which on a fast connection is not the bottleneck at all. Decomposing a manifest into chunk jobs and running them in parallel is the difference between 2-3 MB/s and roughly 70 MB/s on the same line. This project is mostly that, plus Workshop support.

Why these choices

Writing chunks at their offset

This is the piece that makes parallelism safe. Every chunk declares its own offset, so writes never need to be ordered and never need to hold a lock on a shared position. Two workers writing different chunks of the same file is not a race.

csharpCore/Downloads/ChunkedFileWriter.cs
file = File.OpenHandle(
    fileDirectory,
    FileMode.OpenOrCreate,
    FileAccess.ReadWrite,
    FileShare.Read,
    FileOptions.Asynchronous | FileOptions.RandomAccess
);

public async Task WriteChunkAsync(DepotManifest.ChunkData chunk, Memory<byte> data)
{
    try
    {
        var offset = (long)checked(chunk.Offset);
        await RandomAccess.WriteAsync(file, data, offset);
    }
    catch (Exception ex)
    {
        throw new Exception($"Failed to write chunk {chunk.Offset} of {fileName} ({ex.Message})");
    }
}

checked on the cast because a malformed manifest offset would otherwise wrap silently and write to the wrong part of the file. The exception message includes the offset, which is the only thing you need to identify a bad chunk.

The downloader is 30 lines of scheduling

Flatten every depot's manifest into a list of jobs, then run them with a bounded degree of parallelism. All the complexity is in DownloadChunkAsync underneath: verify, decrypt, retry with a different CDN server.

csharpCore/Downloads/DownloadManager.cs
var dop = GetChunkParallelism();
AnsiConsole.MarkupLine($"[grey]downloader[/] chunk parallelism={dop} (override via {ChunkParallelismEnv})");

await Parallel.ForEachAsync(
    chunkJobs,
    new ParallelOptions { MaxDegreeOfParallelism = dop },
    async (job, ct) =>
    {
        await DownloadChunkAsync(
            job.chunk, manifestInfo.DepotId, manifestInfo.DepotKey,
            job.file, cdnClient, cdnServers,
            depotProgress, overallProgress);
    });

The finally block around this disposes every open file handle. SafeFileHandle would do it on GC, but with hundreds of open depots that's a real handle leak for the length of a large download.

Resumability without a database

A chunk that's already on disk gets verified by Adler32 instead of re-downloaded. That means an interrupted download resumes by re-running the same command - no checkpoint file, no partial-state tracking, because the disk is the checkpoint.

Thread count is the other half of it. The default scales with the machine, but an env var override exists because the right number depends on the CDN, not the CPU.

csharpCore/Downloads/DownloadManager.cs
private static int GetChunkParallelism()
{
    var env = Environment.GetEnvironmentVariable(ChunkParallelismEnv);
    if (int.TryParse(env, out var parsed) && parsed > 0)
        return Math.Clamp(parsed, 1, 256);

    int dynamicDefault = Environment.ProcessorCount + 4;
    int minThreads = Math.Max(4, Environment.ProcessorCount);
    return Math.Clamp(dynamicDefault, minThreads, 32);
}

Two different clamps deliberately: the env var range is wide because the user asked for it, the automatic range is conservative because it was guessed.

Rotating away from bad CDN servers

Some CDN servers throttle or fail repeatedly. A counter per server takes it out of rotation after two failures, so one slow node can't eat the whole download's parallelism.

csharpCore/Downloads/DownloadManager.cs
private const int MaxServerFailures = 2;
private readonly ConcurrentDictionary<Server, int> serverFailures = new();

private bool IsServerFailed(Server server)
    => serverFailures.TryGetValue(server, out var failures) && failures >= MaxServerFailures;

A ConcurrentDictionary rather than a locked list because this is read on the hot path by every one of the 32 workers, on every chunk.

Current limitations