Skip to content

Documenting .lin format used for some console titles #125

Description

@landaire

Over the past couple weeks I've been reverse engineering the .lin files used in Splinter Cell for the original Xbox and am currently in the process of writing a blog post about how cursed the format is. I saw some discussion in #104, but I thought that this might warrant a dedicated issue for documenting the format. I'll try to be succinct as not to overwhelm with info, and feel free to ask any clarifying questions for anything. I understand that some of this is known, but just to centralize everything I'll include everything anyways:

Some high-level notes about the format:

  • File sizes are wrong
  • Byte sizes are wrong
  • Offsets are wrong
  • Seeks are fake
  • Data can only be read forward
  • All data is load-order dependent

Each map directory contains at least two files:

  • common.lin contains a file table and some shared data
  • mapname.lin (like menu.lin) contains level-specific data.

The files (at least for SC1 on OG Xbox) have the following outer .lin structure:

u32 decompressed_data_len
u32 compressed_data_len
byte zlib_block[compressed_data_len]

For common.lin in this game, the first 4 independently-compressed zlib blocks are:

uncompressed_data_size: 0x648EEE
texture_cache_size (? - later used when calling D3DDevice_CreateTexture2): 0x1B0000
vertex_buffer_size (? - ditto, D3DDevice_CreateVertexBuffer2): 0x6740
index_buffer_size (? - ditto, XGSetIndexBufferHeader): 0xD38

The common.lin and mapname.lin values for these fields get added together after both are decoded.

The common.lin file has a file table not present in map-specific .lin files which looks something like:

/* ==== LIN-Specific Prefix ==== */

// These three, from research + reverse engineering, should not be considered
// as part of the "whole" file
uint32_t        maybe_load_address; // 5C 58 9E 13 in common.lin
compressed_int  name_length;        // 0 in common.lin
cstr            name;

/* ==== Begin "true" common.lin file header ==== */

uint32_t        magic;              // 0x9fe3c5a3 in little endian, i.e. A3 C5 E3 9F

// unk_address - load_address gives you the start of the file table, relative to the magic?
uint32_t        unk_address;        // B4 92 9B 13, suspiciously similar to maybe_load_address
uint32_t        load_address2;      // 5C 58 9E 13 same as maybe_load_address

uint8_t         unknown[8];         // 01 00 00 00 04 2A D6 FE
compressed_int  file_entry_count;

struct FileEntry {
    compressed_int  name_len;
    cstr            name;
    uint32_t        offset;
    uint32_t        len;
    uint32_t        unk;
}

Note: As far as I can tell the suspected load addresses never change for any .lin file. The load addresses don't matter anyways -- nothing gets mapped to these addresses at runtime and for reasons explained further down they're irrelevant.

The partial file table for SC1 is:

FileEntry {
    name: Maps\\menu\\menu.unr,
    offset: 0x0,
    len: 0xDEEE,
    unk: 0x0,
},
FileEntry {
    name: Maps\\1_1_0Tbilisi.unr,
    offset: 0xDEF0,
    len: 0x17C96D,
    unk: 0x0,
},
FileEntry {
    name: Maps\\1_1_1Tbilisi.unr,
    offset: 0x18A860,
    len: 0x213498,
    unk: 0x0,
},
FileEntry {
    name: Maps\\1_1_2Tbilisi.unr,
    offset: 0x39DD00,
    len: 0x196389,
    unk: 0x0,
},
FileEntry {
    name: Maps\\0_0_2_Training.unr,
    offset: 0x534090,
    len: 0xC9F0F,
    unk: 0x0,
},
FileEntry {
    name: Maps\\0_0_3_Training.unr,
    offset: 0x5FDFA0,
    len: 0x118648,
    unk: 0x0,
},
FileEntry {
    name: Maps\\1_2_1DefenseMinistry.unr,
    offset: 0x7165F0,
    len: 0x249AF6,
    unk: 0x0,
},
FileEntry {
    name: Maps\\1_2_2DefenseMinistry.unr,
    offset: 0x9600F0,
    len: 0x20F662,
    unk: 0x0,
},

The offsets and lengths in this table are not to be trusted. They are simply wrong. The last entry has an offset of 0x9600F0 which is very much outside of the range of the common.lin, and for known maps (like menu.lin) the data doesn't make sense no matter how you spin it. It's not at offset 0x0 in either file, and it's not 0xDEEE bytes long.

The offsets and byte sizes in this format do not matter because there are various tricks used to fake the sizes of data read and the file readers cannot seek.

Because the underlying data is compressed, you cannot easily map a decompressed offset to a compressed offset and partially decompress data. The game engine, through reverse engineering, treats a Seek() as a position property update and the underlying reader's Seek() is a no-op.

The data can only be read going forward and seeking never actually happens.

The file table is only read as a consequence of parsing the data before it, and the file that comes after the file table data is only read as a consequence of it always being the first file that's read. Without knowing the first file that's read, you can carve out all Unreal Engine Package files (magic 0x9E2A83C1), but those will have incorrect exports.

The first package in SC1 is Engine which has the following header (your lib parses this fine btw):

pub struct PackageHeader<'i> {
    pub version: u32,
    pub flags: u32,
    pub name_count: u32,
    pub name_offset: u32,
    pub export_count: u32,
    pub export_offset: u32,
    pub import_count: u32,
    pub import_offset: u32,

    // Note: this is not in the above documented description
    pub unk: u32,
    // Ditto.
    // Not shown: compressed int for length of this data at this position
    pub unknown_data: &'i [u8],

    pub guid_a: u32,
    pub guid_b: u32,
    pub guid_c: u32,
    pub guid_d: u32,
    // Not shown: compressed int for length of this data at this position.
    pub generations: Vec<GenerationInfo>,
}

PackageHeader {
    version: 0x110064,
    flags: 0x1,
    name_count: 0xE10,
    name_offset: 0x88,
    export_count: 0xFFA,
    export_offset: 0x117AF3,
    import_count: 0x4E,
    import_offset: 0x11783E,
    unk: 0xFF0ADDE,
    unknown_data: [
      ...
    ]
    guid_a: 0x0,
    guid_b: 0x0,
    guid_c: 0x0,
    guid_d: 0x0,
    generations: [
        GenerationInfo {
            export_count: 0xFFA,
            name_count: 0xE10,
        },
    ],
}

The described offsets are wrong. The counts are correct. If you just parse the data up to the end of the export table end, everything is fine.

For SC1 the first object that the engine attempts to resolve is Engine.GameEngine, which forces the System\Engine.u script to be loaded and the GameEngine export to be parsed. This of course requires resolving its super objects, which triggers a read of its parent types Engine.Engine, Core.Subsystem(? I think this is in Core), and Core.Class.

Since all of these are lazy-loaded, the exact load order is:

  1. Engine.u header read/parse
  2. Engine's GameEngine export lookup
  3. Engine.Engine object lookup
  4. Core.Subsystem object lookup
  5. Core.u header read/parse
  6. Core.Class object lookup
  7. Core.Class property deserialization
  8. Core.Subsystem property deserialization
  9. Engine.Engine property deserialization
  10. Engine.GameEngine property deserialization

Upon finishing deserializing an export, the game engine gets the stream position, calculates the delta from the recorded pre-serialization position, and asserts that the read size equals the export's SerialSize.

Some of these properties seem to trigger a seek to do reads (possibly to parse the property class type info?), which you'd think would throw off that logic. But those cases seem to restore their prior position when finishing their work. Since seeks are a nop, this causes the stream to advance but restores the position field used for the above logic, so the deserializer thinks that the accurate amount of data was read even if it wasn't.

This means however that for an export with an offset of 0x0 and size of 0x1c, the complete deserialization sequence may start at 0x0 and end at (random number here) 0x100 depending on what its properties contain, whether or not its parent types have been deserialized yet, and their properties as well.

This inherently means that this format is 100% load-order dependent. Successful deserialization of this data depends on examining which order the game itself loads data, or else your exports will be reading garbage data from incorrect positions.

IF this load order were obtained and documented (which I can do), this library might be able to enter into an "ignore seeks, they aren't real" mode that loads the exports in the exact sequence that the game loads them when entering a map. From there you can prune any unloaded exports/imports and statically reserialize the file with correct sizes/offsets. I don't know enough about your implementation to say how difficult this would be in practice.

Note that I've managed to dump some packages from the game by doing some binary patches and calling the serialization routine at runtime. These files do successfully load in UE Explorer.

I'll link the blog post once it's out which will have deeper technical details so that the work can be reproduced.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions