DEV Community

Cover image for Parsing Steam Achievement Sets: DLC ownership, archived achievements and a batch-write gotcha
zevnda
zevnda

Posted on Edited on Originally published at steamgameidler.com

Parsing Steam Achievement Sets: DLC ownership, archived achievements and a batch-write gotcha

On October 15, 2026, Valve put Achievement Sets into the Steam Client Beta. Developers can now split a game's achievements into groups (the base game, each DLC, single-player vs multiplayer, and so on), and each group gets its own progress bar and its own "set complete" state.

For players, that's mostly good news. For anyone writing code that reads or writes Steam achievements, there are two changes that matter:

  1. The stats schema has a new section, and achievements now point into it.
  2. Steam's servers refuse to unlock an achievement from a DLC set the account doesn't own, or from an archived set.

I maintain Steam Game Idler, a desktop app (Tauri + Rust, with a C# helper that talks to Steam) that includes an achievement manager. The second change broke "unlock all" for some games on the day the beta landed. This post covers what we found in the schema, why one rejected achievement took down a whole batch, and the fix.

If you only want the player-facing side (how sets count toward completion, how to join the beta, which games support it), I wrote that up separately: Steam Achievement Sets: what the beta changes for achievement hunters.

The feature in one paragraph

A game can have up to 40 sets, counting the base game set. There are base game sets, which everyone can earn, and DLC sets, which only count toward your completion once you own that DLC. Developers can also archive an achievement or a whole set when it can no longer be earned (a shut-down online mode, a past seasonal event). Archived achievements stay on the profiles of people who earned them and stop blocking everyone else's 100%. According to the Steamworks docs, all of it is configured in Steamworks, with no changes to the game's code.

That last point is why it hit third-party tools first. Nothing about the game's binary changes. Only the schema and the server-side rules do.

What changed in the schema

Steam describes a game's achievements and stats in a binary KeyValues (VDF) schema. It's the same format whether you read the local client's cached UserGameStatsSchema_<appid>.bin or receive schema bytes over the wire from Steam's CM servers. Achievements live as bits inside stats of type 4/5, under stats → <stat id> → bits → <bit number>.

Achievement Sets add a top-level groups section next to stats, and a groupid on each achievement bit. Roughly (shown as text VDF for readability, based on a freshly downloaded Euro Truck Simulator 2 schema):

"227300"
{
    "stats"
    {
        "1"
        {
            "bits"
            {
                "0"
                {
                    "name"     "ACH_SOME_DLC_ACHIEVEMENT"
                    "groupid"  "3"
                    "display"  { ... }
                }
            }
        }
    }
    "groups"
    {
        "3"
        {
            "name"      { "english" "DLC: ..." }
            "dlcappid"  "<the DLC's app id>"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

A few details worth knowing if you're parsing this yourself:

  • The base game set is implicit. Group 0 never appears in groups. Any achievement with no groupid, or with a groupid that isn't listed, belongs to the base game set.
  • A DLC set is a group with a dlcappid. That's the only thing that distinguishes it from a base game set like "Driving Academy".
  • archived exists per achievement as an int 1 on the bit itself. Team Fortress 2's schema already has four archived achievements (the Replay "YouTube views" ones). We also read an archived key on a whole group, but as of this writing we haven't seen a real archived set in the wild, so that key name is an educated guess. A missing key reads as "not archived", so a wrong guess fails harmlessly.
  • It's purely additive. A schema without sets has no groups key at all. Old parsers keep working; they just don't know about sets.

The parsing itself is small. Here's the group half, lightly trimmed from our C# helper:

public static List<AchievementGroupDto> ParseAchievementGroups(
    uint appId, byte[] schemaBytes, string language = "english")
{
    var result = new List<AchievementGroupDto>();
    var kv = KeyValue.LoadFromBytes(schemaBytes);
    var groups = kv?[appId.ToString(CultureInfo.InvariantCulture)]["groups"];
    if (groups is null || !groups.Valid || groups.Children == null)
        return result; // no sets: everything is in the implicit base game set

    foreach (var group in groups.Children)
    {
        if (!group.Valid || !int.TryParse(group.Name, out var groupId))
            continue;

        var dlcAppId = group["dlcappid"].AsInteger(0);
        result.Add(new AchievementGroupDto
        {
            Id = groupId,
            Name = ResolveLocalizedString(group["name"], language),
            DlcAppId = dlcAppId > 0 ? (uint)dlcAppId : null,
            Archived = group["archived"].AsBoolean(false),
        });
    }
    return result;
}
Enter fullscreen mode Exit fullscreen mode

The part that broke: one bad unlock sinks the whole batch

Valve's documentation is explicit that Steam "will not unlock an achievement in a DLC set for a player who can't play that DLC, or an achievement in an archived set." That check runs on Steam's servers, so no client-side tool can get around it.

The rule itself is fine. What caught us out was the failure mode. An "unlock all" writes every achievement in a single batched stats store. On Euro Truck Simulator 2, with an account that didn't own one of the DLCs, Steam didn't skip the DLC achievements. It rejected the entire write with EResult.InvalidParam. One unowned DLC achievement meant zero achievements unlocked.

We weren't the only ones. Within days, Steam Achievement Manager users reported it "not working" on games in the sets beta, and the explanation in that thread is the same ownership check.

The fix: know ownership before you write

The fix is to filter the batch before sending it, which means knowing which DLC the account owns. Our app has two backends, and they answer that question differently.

With a local Steam client (Steamworks.NET): ask the client directly.

var groups = SchemaWalker.ParseAchievementGroups(appId, schema, language)
    .Select(group => group.DlcAppId is uint dlcAppId
        ? group with { Owned = SteamApps.BIsSubscribedApp(new AppId_t(dlcAppId)) }
        : group)
    .ToList();
Enter fullscreen mode Exit fullscreen mode

The gotcha here is picking the right call. BIsDlcInstalled looks like the obvious choice, but it reports an owned DLC that isn't downloaded as unowned. BIsSubscribedApp answers for the signed-in account whether or not the DLC is installed, including DLC borrowed through Family Sharing.

Without a local client (SteamKit2, talking to Steam's servers directly): there's no BIsSubscribedApp. We already resolve the account's licenses through PICS to build the owned games list, and a package's appids include its DLC. So DLC ownership is a set lookup against data we already have. The lookup is skipped entirely for games with no DLC sets.

Then the bulk unlock skips anything Steam would reject:

var unownedDlcGroupIds = unlock
    ? AchievementGroupDto.UnownedDlcGroupIds(groups)
    : new HashSet<int>();

foreach (var achievement in achievements)
{
    if (achievement.Achieved == unlock
        || achievement.ProtectedAchievement
        || (unlock && achievement.Archived)
        || (achievement.GroupId is int groupId && unownedDlcGroupIds.Contains(groupId)))
    {
        skipped.Add(achievement.Id);
    }
    else
    {
        toChange.Add((achievement.Id, unlock));
    }
}
Enter fullscreen mode Exit fullscreen mode

The check only applies to unlocking. Locking an achievement you've already earned, archived or not, is still allowed.

Fail open, not closed

The ownership lookup is a network call, and network calls flake. We model ownership as a nullable bool:

  • true: owned, unlock normally.
  • false: known unowned, skip.
  • null: not a DLC set, or the lookup failed.

null is treated as owned. If PICS times out, we send the unlock and let Steam decide, rather than quietly refusing achievements the user might own. A missed skip costs one rejected write that the user can retry. A false skip silently blocks something that would have worked, and nobody would ever report it. The failure gets logged as a warning, so it shows up in the user's log file if it keeps happening.

Showing it in the UI

Once groups are parsed, the UI follows from them. The achievement manager lists All achievements, the Base game set and each DLC or other set in a sidebar. Achievements from DLC you don't own stay visible but are marked, never sent in an unlock, and left out of the game's progress count, which matches how Steam now counts completion. In the automated unlocker, they sit in a separate "From DLC you don't own" section outside the unlock order, so a long run never stalls on an unlock Steam was always going to reject.

Steam Game Idler's Achievement Manager for American Truck Simulator, listing achievements by base game and DLC set

Takeaways if you touch Steam achievements

  • Parse groups and groupid if you display achievements. A missing or unknown groupid means the base game set.
  • Filter batched writes yourself. Steam rejects the entire batch on one unowned-DLC or archived achievement, not just the offending one.
  • Use BIsSubscribedApp, not BIsDlcInstalled, for ownership.
  • Decide what a failed ownership lookup means before it happens. We chose fail open, and logged it.
  • Expect the beta to move. The archived-set key is still unconfirmed, and Valve has said it'll keep changing things during the beta.

If you play achievement-heavy games and want to manage achievements set by set, Steam Game Idler is free and already supports sets. The full player-side guide, with the list of games that launched with sets and how to join the beta, is on the Steam Game Idler blog.

Top comments (2)

Collapse
 
officialmailkr profile image
오피셜메일 •

DLC 소유 여부 조회가 실패했을 때 전체 배치 거부를 감수하는 선택을 명시한 부분이 좋았습니다. 다만 null이 ‘DLC가 아님’과 ‘조회 실패’를 함께 뜻하니, 진단 로그에서는 두 원인을 별도 상태로 남기면 재시도 대상과 정상적인 기본 세트를 구분하기 쉬울 것 같아요. 미소유 DLC 하나가 포함된 배치와 조회 실패 배치를 각각 회귀 테스트로 남기기에도 도움이 되겠습니다.

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

The BIsSubscribedApp vs BIsDlcInstalled distinction is the kind of detail that saves a lot of debugging. Good catch spelling that one out.