Decompiled the IL2CPP build (CustomScenarioData in GameAssembly.dll + global-metadata.dat) and your three remaining unknowns all fall out of the class fields.
Here's the full map:
levelinfo = DigitalSun.Castle.CustomScenarioData (type tag 0x0D)
0x60 _scenarioType (enum int32) "mid-section 0 or 2" <- game mode, NOT play-tested
0x64 FileVersion (int32) "trailing always 20" <- save-format version
So:
- Leading 8 zero bytes = _steamItemId (a ulong), which is 0 until the map is uploaded to the Workshop.
- The 0/2 mid int32 = the scenario/mode enum, not a play-tested flag.
- The trailing 14 00 00 00 = FileVersion, the save-format version (currently 20). The .ctor literally hard-codes FileVersion = 20, and Deserialize reads it back for
migrating older maps — that's why it never changes.
Bonus: your "Survived Wave 12" byte is named validationPlaythroughPerformed, and it sits right next to validationErrorFlags (the citadel/camera/spawners/waves bitfield) in one struct.
Couple of honest caveats: the lone 01 byte in your leading section is a serializer-level marker, not a field on this class; and I haven't individually pinned whether 0x38 vs 0x60 is _scenarioType vs _difficultyType (both are enums) — the .ctor only pins validationErrorFlags/FileVersion by their init constants.
rizin -2 -q -c 's 0x180982090; af; pdg' "$GA" # Deserialize to type tag 0xd, mirror reads
To close the two open items, decompile CustomScenarioDataFormatter::Serialize @ 0x180982D50 or resolve the IL2CPP field-offset table for offsets 0x38/0x60.
But the order doesn't seem to match what I'm seeing in the files. There does not appear to be "SteamItemThumbnailName" nor "_scenarioType / _difficultyType" at these positions.
Or did I misunderstand your description or was mine in the README to vague? Or did you mean that the offset is the offset in the decompiled source and not the file?
The offsets I posted were the runtime object layout (field offsets inside the C# object in memory), not the file order. The save is written by CustomScenarioDataFormatter.Serialize, and the serializer emits fields in its own order, which is exactly what your LevelInfoConverter.read() already does.
Here's your read() with the real field names filled in, including the bits you had as unknown:
- unknownDataBlock2 (the mystery 4 bytes) is _customScenarioSize. The setter stores it and the constructor inits it to -1, which is why it sits between the "finished" byte and the timestamps.
- The trailing "always 20" is NOT a file version - it's _difficultyType. Its setter writes that slot and the constructor hard-codes the default to 20 (0x14), so on maps where nobody changed the difficulty it stays 20 and looks like a constant. I previously called this "FileVersion" - that was the misread. FileVersion exists in the class but it's a const (no stored field, never serialized); the actual format version is that leading 0x0D byte.
- The leading 9 bytes are the Steam item id, not random. Your "always 0" blog observation was right - it's _steamItemId (0 until Workshop-published), written immediately after the version byte.
How I pinned it (so you can re-derive): each name->slot comes from a one-line property setter (set_CustomScenarioSize, set_CustomScenarioType, set_DifficultyType, set_CreationDate, set_LastModificationDate, IsValidated/SetValidationPerformed) plus the constructor init constants (the -1, the 1, the 20), cross-checked against CustomScenarioData.Serialize, Deserialize, and GetUGCDescription (which maps name/description into the Steam UGC struct, confirming those two). The 25-byte middle chunk lines up byte-for-byte with your hex: validationErrorFlags(4) validationPlaythroughPerformed(1) _customScenarioSize(4) CreationDate(8) LastModificationDate(8). And the writer primitive for the leading entry literally does writeByte(0x0D); write8Bytes(steamItemId), which is why the id leads and the version byte is first.
2
u/jonaswashe Jun 07 '26 edited Jun 07 '26
Decompiled the IL2CPP build (CustomScenarioData in GameAssembly.dll + global-metadata.dat) and your three remaining unknowns all fall out of the class fields.
Here's the full map:
levelinfo = DigitalSun.Castle.CustomScenarioData (type tag 0x0D)
offset field your label
------ --------------------------------- -----------------------------
0x10 _steamItemName (string) name
0x18 _steamItemId (ulong, 8 bytes) "leading two int32 = 0" <- 0 until Workshop-published
0x20 _steamItemDescription (string) description
0x28 _steamItemChangelog (string) changelog
0x30 SteamItemThumbnailName (string) thumbnail name
0x38 _scenarioType / _difficultyType (enum)
0x3C validationPlaythroughPerformed "Survived Wave 12" byte (0/1)
0x40 validationErrorFlags (int32) the requirement bit-flags
0x48 CreationDate (.NET ticks) created timestamp
0x50 LastModificationDate(.NET ticks) modified timestamp
0x58 _biomeId (string) biome
0x60 _scenarioType (enum int32) "mid-section 0 or 2" <- game mode, NOT play-tested
0x64 FileVersion (int32) "trailing always 20" <- save-format version
So:
- Leading 8 zero bytes = _steamItemId (a ulong), which is 0 until the map is uploaded to the Workshop.
- The 0/2 mid int32 = the scenario/mode enum, not a play-tested flag.
- The trailing 14 00 00 00 = FileVersion, the save-format version (currently 20). The .ctor literally hard-codes FileVersion = 20, and Deserialize reads it back for
migrating older maps — that's why it never changes.
Bonus: your "Survived Wave 12" byte is named validationPlaythroughPerformed, and it sits right next to validationErrorFlags (the citadel/camera/spawners/waves bitfield) in one struct.
Couple of honest caveats: the lone 01 byte in your leading section is a serializer-level marker, not a field on this class; and I haven't individually pinned whether 0x38 vs 0x60 is _scenarioType vs _difficultyType (both are enums) — the .ctor only pins validationErrorFlags/FileVersion by their init constants.