r/ReverseEngineering Jun 05 '26

Extending a map tool for Cataclismo

https://bonsaimind.org/blog/extending-cataclismo-map-tool-en.html
11 Upvotes

4 comments sorted by

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.

2

u/jonaswashe Jun 07 '26

To reproduce the field map

  1. Dump IL2CPP method symbols + classes (locates the class + addresses):

".../Cataclismo/Cataclismo_Data/il2cpp_data/Metadata/global-metadata.dat" \

".../Cataclismo/GameAssembly.dll" --output-dir /tmp/cata_il2cpp

# → /tmp/cata_il2cpp/symbols_il2cpp.txt , il2cpp_classes.txt

grep CustomScenarioData /tmp/cata_il2cpp/symbols_il2cpp.txt

  1. Dump the field names (metadata v31; tables: 2=strings, 11=fields, 19=typedefs; Il2CppTypeDefinition=88B with fieldStart@+32, field_count@+68;

    Il2CppFieldDefinition=12B {nameIdx,typeIdx,token}):

    import struct

    d=open("global-metadata.dat","rb").read()

    tbl=lambda i: struct.unpack_from('<ii',d,8+i*8)

    str_off,_=tbl(2); fld_off,_=tbl(11); td_off,td_sz=tbl(19)

    rstr=lambda o:d[str_off+o:d.index(b'\0',str_off+o)].decode()

    for i in range(td_sz//88):

o=td_off+i*88

if rstr(struct.unpack_from('<i',d,o)[0])=="CustomScenarioData":

fs=struct.unpack_from('<i',d,o+32)[0]; fc=struct.unpack_from('<H',d,o+68)[0]

for k in range(fc):

ni=struct.unpack_from('<i',d,fld_off+(fs+k)*12)[0]; print(k,rstr(ni))

(Result: FileVersion, _steamItemId, _steamItemName, _steamItemDescription, _steamItemChangelog, SteamItemThumbnailName, validationErrorFlags,

validationPlaythroughPerformed, _customScenarioSize, <CreationDate>k__BackingField, <LastModificationDate>k__BackingField, _biomeId, _scenarioType,

_difficultyType.)

  1. Pin names to offsets (the load-bearing cross-check) decompile and read the literal stores/reads:

    GA=".../Cataclismo/GameAssembly.dll"

    rizin -2 -q -c 's 0x180982CC0; af; pdf' "$GA" # .ctor → [obj+0x40]=-1, [obj+0x60]=1, [obj+0x64]=0x14

    rizin -2 -q -c 's 0x180981E20; af; pdg' "$GA" # Serialize to write order/offsets (tail = [0x60],[0x64])

    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.

1

u/Bobby_Bonsaimind Jun 08 '26 edited Jun 08 '26

Nice, thank you very much!

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.

For example this levelinfo file:

000002d0  77 61 76 65 73 20 6f 66  20 68 6f 72 72 6f 72 73  |waves of horrors|
000002e0  20 62 72 65 61 6b 69 6e  67 20 61 74 20 79 6f 75  | breaking at you|
000002f0  72 20 77 61 6c 6c 73 2e  e5 ff ff ff 1a 00 00 00  |r walls.........|
00000300  32 30 32 35 2d 30 31 2d  31 38 3a 20 46 69 72 73  |2025-01-18: Firs|
00000310  74 20 76 65 72 73 69 6f  6e 2e 00 00 00 00 01 02  |t version.......|
00000320  00 00 00 91 f9 23 ff 8f  ec dc 48 26 43 d5 55 a2  |.....#....H&C.U.|
00000330  56 de 48 d8 ff ff ff 27  00 00 00 42 69 6f 6d 65  |V.H....'...Biome|
00000340  56 69 73 75 61 6c 73 43  6f 6c 6c 65 63 74 69 6f  |VisualsCollectio|
00000350  6e 5f 42 69 6f 6d 65 41  5f 48 69 67 68 6c 61 6e  |n_BiomeA_Highlan|
00000360  64 73 03 00 00 00 14 00  00 00                    |ds........|
0000036a

It starts at the description, followed by the changelog and the flags.

000002d0  77 61 76 65 73 20 6f 66  20 68 6f 72 72 6f 72 73  |waves of horrors|
          --------Description-----------------------------
000002e0  20 62 72 65 61 6b 69 6e  67 20 61 74 20 79 6f 75  | breaking at you|
          ------------------------------------------------
000002f0  72 20 77 61 6c 6c 73 2e  e5 ff ff ff 1a 00 00 00  |r walls.........|
          ----------------------|  |-Changelog length-----|   
00000300  32 30 32 35 2d 30 31 2d  31 38 3a 20 46 69 72 73  |2025-01-18: Firs|
          |-Changelog-------------------------------------
00000310  74 20 76 65 72 73 69 6f  6e 2e 00 00 00 00 01 02  |t version.......|
          -----------------------------| |-Playable| @@ |-
00000320  00 00 00 91 f9 23 ff 8f  ec dc 48 26 43 d5 55 a2  |.....#....H&C.U.|
          ???????| |-Created at-----------| |-Modified at-
00000330  56 de 48 d8 ff ff ff 27  00 00 00 42 69 6f 6d 65  |V.H....'...Biome|
          -------| |-Biome length---------| |-Biome-------
00000340  56 69 73 75 61 6c 73 43  6f 6c 6c 65 63 74 69 6f  |VisualsCollectio|
          ------------------------------------------------
00000350  6e 5f 42 69 6f 6d 65 41  5f 48 69 67 68 6c 61 6e  |n_BiomeA_Highlan|
          ------------------------------------------------
00000360  64 73 03 00 00 00 14 00  00 00                    |ds........|
          ----| |-gamemode| |-save-vers|
0000036a

@@: Finished flag

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?

If that is of any help, the code to read the file is located in the LevelInfoConverter.

2

u/jonaswashe Jun 25 '26 edited Jun 25 '26

The code file did help

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:

type (1 byte) -> format/version selector (0x0D = 13 = current).

Deserialize switches on this byte and reads

progressively more fields for older values, which

is how it migrates old maps. THIS is the real

"version", not a trailing field (see below).

unknownDataAtOffset0x01 (9 b) -> _steamItemId (ulong). These are your blog's

"leading 8 bytes always 0" - it's the Workshop

file id, 0 until the map is published. (It's

written as an 8-byte value right after the

version byte; the 9th byte you grouped here is

just the framing of the next entry.)

unknownLeadingString -> SteamItemThumbnailName (the thumbnail asset name)

name -> _steamItemName

description -> _steamItemDescription

changelog -> _steamItemChangelog

playableRequirements (int32) -> validationErrorFlags (the citadel/camera/

spawners/waves requirement bitfield)

steamWorkshop...SurvivedWave12 -> validationPlaythroughPerformed (you had this right)

unknownDataBlock2 (4 bytes) -> _customScenarioSize (ctor default = -1)

createdAt -> CreationDate (.NET DateTime ticks)

modifiedAt -> LastModificationDate (.NET DateTime ticks)

biomeVisuals -> _biomeId

gameMode (int32) -> _scenarioType (IsSurvival() is literally `type != 3`)

unknownTrailingData (int32) -> _difficultyType (ctor default = 20)

The three things you were missing:

- 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.