This document represents about a year and a half of off-and-on hobby-research on reverse engineering the digitizing raster/vector art program PaintTool Sai. This write-up in particular is focused on the technical specifications of the user-created .sai
file format used to archive a user's artwork and the layers of abstraction implemented by SYSTEMAX for extracting this data outside of the context of the original software. This document is more directed at anyone that wants to implement their own library to read or interface with .sai
files or just to get a comprehensive understanding of the decisions that SYSTEMAX has chosen to make for their file format. If you find anything in this document to be misleading, incomplete, or flat-out incorrect feel free to shoot me an email at Wunkolo (at) gmail.com
. Previous work includes my now-abandoned run-time exploitation framework SaiPal and the more recent Windows explorer thumbnail extension SaiThumbs. This document assumes you have some knowledge of the C and C++ syntax as the data structures and algorithms here will be presented in the form of C and C++ structures and subroutines.
PaintTool SAI Ver.1
PaintTool SAI is high quality and lightweight painting software, fully digitizer support, amazing anti-aliased paintings, provide easy and stable operation, this software make digital art more enjoyable and comfortable.
SYSTEMAX Software Development
Details:
- Fully digitizer support with pressure.
- Amazing anti-aliased drawings.
- Highly accurate composition with 16bit ARGB channels.
- Simple but powerful user interface, easy to learn.
- Fully support Intel MMX Technology.
- Data protection function to avoid abnormal termination such as bugs.
Copyright 1996-2016 SYSTEMAX Software Development
Sai uses the file type .sai
as its document format for storing both raster and vector layers as well as other canvas related meta-data. The .sai
file among with other files such as thumbnails, the sai.ssd
file and others is but an archive containing a file-system-like structure once decrypted. Each layer, mask, and related meta data is stored in an individual pseudo-file which also has a layer of block-level encryption. The file itself is encrypted in ECB blocks in which any randomly accessed block can be decrypted by also decrypting the appropriate Table-Block
and accessing its 32-bit key found within. It's been found that some preliminary files such as thumbnails and the archive responsible for swatches/palettes use a different decryption key, block size, and Table-Block
location. This document will mostly cover the method used for sai's user created .sai
documents and very partially show related information for the other files.
An individual block in a .sai
file is 4096
bytes of data. Every block index that is a multiple of 512
(0, 512, 1024, etc
) is a Table-Block
containing meta-data about the block itself and the 511
blocks after it. Every other block that is not a Table-Block
is a Data-Block
:
// Gets the Table-Block index appropriate for the current block index
std::size_t NearestTable(std::size_t BlockIndex)
{
return BlockIndex & ~(0x1FF);
}
// Demonstrating how to quickly determine if a block Index is a data-block or a table-block
bool IsTableBlock(std::size_t BlockIndex)
{
return (BlockIndex & 0x1FF) ? false:true;
}
bool IsDataBlock(std::size_t BlockIndex)
{
return (BlockIndex & 0x1FF) ? true:false;
}
All blocks are encrypted and decrypted symmetrically using a simple exclusive-or-based encryption which refers to a static atlas of 256 32-bit integers which can be found at the end of this text. Different files related to Sai use different static keys. The keyvault used for the .sai
file will be referred to as the UserKey
since this is the only symmetrical key used to decrypt and encrypt files generated by the end-ser. Table-Blocks
and Data-Blocks
are encrypted differently using the same UserKey
.
Table-Blocks
can be decrypted by random access using only their multiple-of-512 block index and the the UserKey
. The first block of a .sai
file (block index 0) will be a Table-Block
storing related data for the 511
blocks after it. When decrypting a Table-Block
, four of the 256 keys within UserKey
are indexed by the four bytes of the 32-bit block-index and then summed together. This sum is exclusive-ored with the current 4-byte cipher-word and the block-index followed by a 16-bit left rotation of the result. When decrypting a Data-Block
, an initial decryption vector is given which selects the appropriate integers from UserKey
using the individual bytes of the 32-bit vector integer and xors with the vector integer itself, and subtracts this value from the cipher to get the plaintext before passing on the vector to the next round using the cipher integer. The input Vector
is the checksum integer found in the Table-Block
.
// Ensure BlockIndex is a valid Table-Block index
void DecryptTable(std::uint32_t BlockIndex, std::uint32_t* Data)
{
// see "IsTableBlock" above on making sure BlockIndex
// is a table or use:
// BlockNumber &= (~0x1FF);
for( std::size_t i = 0; i < 1024; i++ )
{
std::uint32_t CurCipher = Data[i];
std::uint32_t X = BlockIndex ^ CurCipher ^ (
UserKey[(BlockIndex >> 24) & 0xFF]
+ UserKey[(BlockIndex >> 16) & 0xFF]
+ UserKey[(BlockIndex >> 8) & 0xFF]
+ UserKey[BlockIndex & 0xFF]);
Data[i] = static_cast<std::uint32_t>((X << 16) | (X >> 16));
BlockIndex = CurCipher;
};
}
void DecryptData(std::uint32_t Vector, std::uint32_t* Data)
{
for( std::size_t i = 0; i < 1024; i++ )
{
std::uint32_t CurCipher = Data[i];
Data[i] =
CurCipher
- (Vector ^ (
UserKey[Vector & 0xFF]
+ UserKey[(Vector >> 8) & 0xFF]
+ UserKey[(Vector >> 16) & 0xFF]
+ UserKey[(Vector >> 24) & 0xFF]));
Vector = CurCipher;
}
}
Table-Blocks
contain 512 8-byte structures containing a a 32-bit checksum and a 32-bit integer used to store an index to the next block(similar to a singly linked list). Each index of table-entries corresponds to the appropriate block index after the table index. The first checksum entry found within the Table-Block
is a checksum of the table itself, excluding the first 32-bit integer. Setting the first checksum to 0 and calculating the checksum of the entire table produces the same results as if the first entry was skipped. A table entry with a checksum of 0
is considered to be an unallocated/unused block.
struct TableEntry
{
std::uint32_t Checksum;
std::uint32_t NextBlock;
} TableEntries[512];
~ ~
Table-Block | |
+----------+----------+<----+---------+
0 |0xChecksum|0xPrelimin| |XXXX|XXXX| Block 512
Checksum used to+--> 1 |0xChecksum|0xPrelimin| |XXXX|XXXX| 0x200200
decrypt block 513 2 |0xChecksum|0xPrelimin| |XXXX|XXXX|
3 |0xChecksum|0xPrelimin| +---------+
4 |0xChecksum|0xPrelimin| /| | Block 513
512 entries 5 |0xChecksum|0xPrelimin| / | | 0x200400
6 |0xChecksum|0xPrelimi.| / | |
7 |0xChecksum|0xPrelim..| / +---------+
8 |0xChecksum|0xPreli...|< | | Block 514
9 |0xChecksum|0xPrel....| | | 0x200600
10 |0xChecksu.| | | |
~ ~ ~ +---------+
| |
~ ~
The checksum for Data-Blocks
and Table-Blocks
is a simple exclusive-or and bit-rotate which interprets all 4096 bytes of the block as 1024 32-bit integers, with the exception that the checksum for Table-Blocks
does not include the first four bytes(the checksum integer of the block itself). All 1024 integers are exclusive-ored with an initial checksum of zero, which is rotated left 1 bit before the exclusive-or operation. Finally the lowest bit is set, making all checksums an odd number.
The NextBlock
integer is a block index used to point to the next block that should be read if one is trying to read a serial stream of data. Ex: A large file that spans multiple blocks will be broken up into multiple blocks, and the table-block will use the "NextBlock" flag to point to the next block that should be read, with "0" being the last block.
// If your block number is a multiple of 512, set `Table` to true.
std::uint32_t Checksum(bool Table, std::uint32_t* Data)
{
std::uint32_t Sum = 0;
for( std::size_t i = (Table ? 1 : 0); i < 1024; i++ )
{
Sum = ( ( Sum << 1 ) | (Sum >> 31)) ^ Data[i];
}
return Sum | 1;
}
// Generic version for both Table-Blocks and Data-Blocks
// Works on tables if you set the first 32-bit integer to 0 before running.
std::uint32_t Checksum(std::uint32_t* Data)
{
std::uint32_t Sum = 0;
for( std::size_t i = 0; i < 1024; i++ )
{
Sum = ( ( Sum << 1 ) | (Sum >> 31)) ^ Data[i];
}
return Sum | 1;
}
A block-level corruption can be detected by a checksum mismatch. If the Data-Block
's generated checksum does not match the checksum found at the appropriate table entry within the Table-Block
then the Data-Block
is considered corrupted.
Sai internally uses a Direct Mapped cache table to speed up the random access and decryption of a file by caching both Table-Blocks
and Data-Blocks
. An arbitrary block number will have its appropriate cache entry looked up by first shifting the BlockNumber
integer right by 14 bits and comparing both the upper 18 bits of the block ID to the lower 31 bits of the cache entry found within the internally mounted file object. Should these two numbers match then a cache-hit has occurred. Otherwise the block is to fully loaded and decrypted into the cache. The the mounted file context object(I've called it VFSObject
in IDA Pro, has exactly 32 cache lines for Table-Blocks
. The highest bit of the cache table line is the dirty
bit which notes if the block is due for a write-back before a new block is to overwrite the entry. Cache size seems to generally be the block-size divided by 8 and will be a different size depending on the file being handled. This cache mechanism is Sai's mechanism to minimize the need for constant file IO stalls at run-time and for efficient file-writing and flushing. Changes are fully "flushed" simply by writing any remaining cache lines to the file with the upper dirty
bit set(and adjusting appropriate checksums within appropriate Table-Blocks
if needed). If you plan to implement a library that reads from .sai
files, you should probably follow the same cache routine to speed up your file access as Sai. Table-Blocks
should at the very least be cached as almost every random access of a .sai
file will require you to read the appropriate Table-Block
before being able to decrypt the Data-Block
.
Now that the cipher can be fully randomly accessed and decrypted, the virtual file system actually implemented can be deciphered. The file system found after decrypting will be described as a Virtual File system
or VFS
(Internally sai refers to them as a VFS
along with terminology such as "mounting" within its error messages). Individual files are described by a File Allocation Table
that describe the name, timestamp, starting block index, and the size(in bytes) of the data. A Data-Block
can contain a max of 64
FATEntries
. Folders are described by having their Type
variable set to Folder
and the starting Block
variable instead points to another Data-Block
of 64 FATEntries
depicting the contents of the folder.
enum class EntryType : std::uint8_t
{
Folder = 0x10,
File = 0x80
};
struct FATEntry
{
std::uint32_t Flags;
char Name[32];
std::uint8_t Pad1;
std::uint8_t Pad2;
std::uint8_t Type; // EntryType enum
std::uint8_t Pad4;
std::uint32_t Block;
std::uint32_t Size;
// Windows FILETIME structure
// https://msdn.microsoft.com/en-us/library/windows/desktop/ms724284(v=vs.85).aspx
std::uint64_t TimeStamp;
std::uint64_t UnknownB;
};
struct FATBlock
{
FATEntry Entries[64];
}
Note: When reading file-data of an FATEntry, files are not stored continuously.
TableBlocks
may intercept the file stream and must be skipped. So when reading filedata you must abstract away table blocks. This means when reading a file, you must skip all table blocks as if they did not exist and skip over them to simulate continuous filesSo offsets such as:
[0,4096],[2097152,2101248],[4194304,4198400],...,[TableIndex * 4096,TableIndex * 4096 + 4096]
must be skipped over
Some info on TimeStamp
: To convert this 64 bit integer to the more standardized time_t
variable simply divide the 64-bit integer by 10000000UL
and subtract by 11644473600ULL
. FILETIME
is the number of 100-nanosecond intervals since January 1, 1601 while time_t
is the number of 1-second intervals since January 1, 1970. If you're writing a multi-platform library it's best to use the more standardized time_t
format when available as most functions converting timestamps into strings use the time_t
format.
time_t filetime_to_time_t(std::uint64_t Time)
{
return Time / 10000000ULL - 11644473600ULL;
}
The root
directory of the VFS
will always start at block index 2
. This will always be the position of the first FATBlock
containing 64 FatEntries
of the root
folder. If the Flags
variable of the FATEntry
structure is 0
the entry is considered to be unused. The full hierarchy of files can be traversed simply by iterating through all 64 entries of the FatBlock
within block index 2
and stopping at the entry whose Flags
variable is set to 0
. If any of the 64 FATEntries
is a folder, then recursively iterate at the 64 FatEntries
at the Block
variable. If the entry is a file then simply go to the starting block index and read Size
amount of bytes continuously, decrypting appropriate Data-Blocks
along the way should Size
be larger than 1 block(0x1000
bytes). Padded bytes within a block will always be 0
.
From this point on it is assumed you are capable of decrypting the file for random access and can interpret the internal file system format. Now we will look at the actual files and the strucutre in which they are placed within this virtual file system.
The actual file/folder structure found within .sai
files describes information on the canvas, layers, a thumbnail image, and other meta-data. Here is a sample file structure of a .sai
file created in October.
/.a1541b366925e034 | 32 bytes | 2016/10/12 03:53:53
/canvas | 56 bytes | 2016/10/12 03:53:53
/laytbl | 60 bytes | 2016/10/12 03:53:53
/layers/ | --- | 2016/10/12 03:53:53
/0000000a | 464007 bytes | 2016/10/12 03:53:53
/00000010 | 452 bytes | 2016/10/12 03:53:53
/0000000e | 361 bytes | 2016/10/12 03:53:53
/00000011 | 373 bytes | 2016/10/12 03:53:53
/00000012 | 373 bytes | 2016/10/12 03:53:53
/0000000f | 538 bytes | 2016/10/12 03:53:53
/0000000b | 82454 bytes | 2016/10/12 03:53:53
/subtbl | 12 bytes | 2016/10/12 03:53:53
/sublayers/ | --- | 2016/10/12 03:53:53
/0000000d | 87213 bytes | 2016/10/12 03:53:53
/thumbnail | 90012 bytes | 2016/10/12 03:53:53
the first entry .a1541b366925e034
will vary in name but will always be the first entry. See .xxxxxxxxxxxxxxxx for more info on this file.
Before going into the file formats a specific format of serialization needs to be explained that is found across the internal files.
Sai.exe internally uses a specially formatted array of 32 bit integers that describe how serialized data is to be read and written to a file. A size of 0
delimits the end of the table.
Format of the Serial-Table found within Sai.exe for the reso
identifier.
Serialization Table for `reso` identifier
0-0x00000004 Serial Entry+-----+------------------------+
0x0000014C <-----------+ | |
1-0x00000002 Serial Entry \ | Size in Bytes |
0x00000150 <-----------+ \ +------------------------+
2-0x00000002 Serial Entry \ | |
0x00000152 <-----------+ \ | Runtime Offset |
0x00000000 End +------------------------+
Runtime Offset
is the offset within the runtime object where Size
amount of data gets written to in memory after reading from the file. In C++ code this would be the offsetof
and sizeof
macro of specific fields of an object being stored in an array. One could trace what an unknown serial entry does by finding what runtime object gets written to and finding out when that specific field gets used again.
SYSTEMAX Source code, probably
struct ResData
{
...
std::uint32_t DPI;//0x14C bytes within some class/struct/etc
std::uint16_t Unknown150;
std::uint16_t Unknown152;
...
};
std::uint32_t ResDataStream[] =
{
sizeof(ResData.DPI),
offsetof(ResData, DPI),
sizeof(ResData.Unknown150),
offsetof(ResData, Unknown152)
};
Output written by the Serial-Table for some arbitrary runtime ResData object
6F 73 65 72 08 00 00 00 00 00 48 00 00 00 00 00
^ ^ ^ ^ ^ ^ ^ ^ ^ ^
+---------+ +---------+ +---------+ +---+ +---+
`oser` Size Serial Ser. Ser.
Data Data Data
0 1 2
oser
is the little endian storage of reso
. In code the identifier oser
is actually defined as something along the lines of:
const std::uint32_t ResDataMagic = `reso`;
Size
is simply the sum of all Size
integers for each Serial Entry
. This integer gets written so that entire streams of unneeded data may be skipped. If two streams reso
and lyid
were next to each other, one could skip to the lyid
stream by reading 32-bit identifier reso
to see that it does not match up with lyid
and use the next 32-bit Size
integer to know the amount of bytes to skip to get to the next stream. A tag identifier of 0
delimits the end of a Serial Stream
.
Sample code for reading a serial stream.
std::uint32_t CurTag;
std::uint32_t CurTagSize;
while( File.Read<std::uint32_t>(CurTag) && CurTag )
{
File.Read<std::uint32_t>(CurTagSize);
switch( CurTag )
{
case 'reso':
{
//Handle 'reso' data
File.Read<std::uint32_t>(...);
File.Read<std::uint16_t>(...);
File.Read<std::uint16_t>(...);
break;
}
case 'lyid':
{
//...
break;
}
case 'layr':
{
//...
break;
}
default:
{
// for any streams that we do not handle,
// we just skip forward in the stream
File.Seek(File.Tell() + CurTagSize);
break;
}
}
}
Serial streams from here on out will be depicted as an enumeration of the four-byte identifier and the formatted data that it contains.
This file name is procedurally generated based on the system that wrote the file. It is a 64 bit hash integer generated from a string involving the information of the motherboard formatted into a %s/%s/%s
string.
Three strings are queried from Windows Management Instrumentation(WMI) first with the query
SELECT * FROM Win32_BaseBoard
and then taking the Manufacturer
, Product
, and SerialNumber
table entries (making sure to convert the UTF16 into UTF8) and formatting them together into a string identifying the user's chipset(formatted %s/%s/%s
). An example chipset:
ASUSTeK COMPUTER INC./Z87-DELUXE/130410781704124
The machine-identifying hash is then calculated with this from this string. Within the hash function this null-terminated string is repeated continuously until it fits a 256 byte span.
ASUSTeK COMPUTER INC./Z87-DELUXE
/130410781704124\ASUSTeK COMPUTE
R INC./Z87-DELUXE/13041078170412
4\0ASUSTeK COMPUTER INC./Z87-DELU
XE/130410781704124\0ASUSTeK COMPU
TER INC./Z87-DELUXE/130410781704
124\0ASUSTeK COMPUTER INC./Z87-DE
LUXE/130410781704124\0ASUSTeK COM
This 256 byte array of characters is then interpreted as 64 32-bit integers for a chained rotate-and-xor hashing function, generating a 64 bit hash.
std::uint64_t MachineHash(const char* MachineIdentifier)
{
std::uint32_t StringBlock[64];
const char* ReadPoint = MachineIdentifier;
for(std::size_t i = 0; i < 256; i++)
{
reinterpret_cast<std::uint8_t*>(StringBlock)[i] = *ReadPoint;
ReadPoint = *ReadPoint ? ++ReadPoint : MachineIdentifier;
}
std::uint32_t UpperHash = 0;
std::uint32_t LowerHash = 0;
std::uint32_t Temp1 = 0;
for(std::size_t i = 0; i < 64; i++)
{
std::uint32_t CurUpper = UpperHash + StringBlock[i % 64];
std::uint32_t CurLower = LowerHash + StringBlock[(i + 1) % 64];
for( std::size_t j = 0; j < 4; j++ )
{
CurUpper = CurLower + ((CurUpper << CurLower) | (CurUpper >> (32 - CurLower)));
CurLower = CurUpper + ((CurLower << CurUpper) | (CurLower >> (32 - CurUpper)));
}
LowerHash = CurLower ^ Temp1;
UpperHash ^= CurUpper;
Temp1 ^= CurLower;
}
return (static_cast<std::uint64_t>(UpperHash) << 32) | LowerHash;
}
The resulting hash for the above formatted string is a1541b366925e034
which would make the filename .a1541b366925e034
using the internal format /%s.%016I64x
. The first string seems to always be null leaving the hash to simply have a period character prepended to it.
The file itself is only 32 bytes long.
struct AuthorSystemInfo
{
std::uint32_t BitFlag; // always 0x08000000
std::uint32_t Unknown4;
std::uint64_t DateCreated; // Date Created
std::uint64_t DateModified; // Date Modified
std::uint64_t MachineHash; // Calculated using the above routine
}
Timestamps are 64 bit integer counts of seconds since January 1, 1601
. This value is calculated using GetSystemTimeAsFileTime and then dividing the 64-bit result by 10000000
to convert from 100-nanosecond-intervals into seconds.
This file contains metadata involving the dimensions of the canvas. The first three integers are a static structure:
struct CanvasInfo
{
std::uint32_t Unknown0; // Always 0x10(16), possibly bpc or alignment
std::uint32_t Width;
std::uint32_t Height
};
After this, a Serial Stream:
reso
// 16.16 fixed point integer
std::uint32_t DotsPerInch;
// 0 = pixels, 1 = inch, 2 = cm, 3 = mm
std::uint16_t SizeUnits;
// 0 = pixel/inch, 1 = pixel/cm
std::uint16_t ResolutionUnits;
wsrc
Layer marked as the selection source
std::uint32_t SelectionSourceID;
layr
std::uint32_t SelectedLayerID;
lyid
Seems to be a duplication oflayr
std::uint32_t SelectedLayerID;
These files contains a description of all layers that make up an image stored from "lowest" layer to "highest". subtbl
contains preliminary layers such as masks. Both laytbl
and subtbl
have the same format and describe the contents within their respective layers
and sublayers
folder.
The first integer of either file is a is a 32bit integer for the number of layers followed by an equivalent amount of LayerTableEntries
. Layers are identified by 32 bit integers with their appropriate filename found in the layers
and sublayers
folder using an 8 digit lowercase hexidecimal file name. The full path for any given layer or sublayer identifier can be generated given the identifying integer and the printf format /layers/%08x
or /sublayers/%08x
.
enum class LayerType : std::uint16_t
{
Null = 0x00,
Layer = 0x03, // Regular Layer
Unknown4 = 0x4, // Unknown
Linework = 0x05, // Vector Linework Layer
Mask = 0x06, // Masks applied to any layer object
Unknown7 = 0x07, //Unknown
Set = 0x08//Layer Folder
};
struct LayerTableEntry
{
std::uint32_t Identifier;
std::uint16_t Type; // LayerType enum
std::uint16_t Unknown6; // Gets sent as windows message 0x80CA for some reason
};
Sample routine:
// First integer is number of layer entires
std::uint32_t LayerCount = File.Read<std::uint32_t>();
while( LayerCount-- ) // Read each layer entry
{
// Read current layer entry into above structure
LayerTableEntry CurrentLayer = File.Read<LayerTableEntry>();
// Do something with this layer
//...
}
The individual layer files within these folders match the numerical hexidecimal identifiers found in laytbl
or subtbl
. These files contain the actual raster or vector data(or none) of the specified layer entry. The header of the file is a static struture identifying the layer's opacity, size, blending mode, etc.
enum BlendingModes : std::uint32_t
{
PassThrough = 'pass',
Normal = 'norm',
Multiply = 'mul\0',
Screen = 'scrn',
Overlay = 'over',
Luminosity = 'add\0',
Shade = 'sub\0',
LumiShade = 'adsb',
Binary = 'cbin'
};
// Rectangular bounds
// Can be off-canvas or larger than canvas if the user moves
// The layer outside of the "canvas window" without cropping
// similar to photoshop
// 0,0 is top-left corner of image
struct LayerBounds
{
// Can be negative, rounded to nearest multiple of 32
std::int32_t X;
std::int32_t Y;
std::uint32_t Width;
std::uint32_t Height;
};
struct LayerHeader
{
std::uint32_t Type; // LayerType enum
std::uint32_t Identifier;
LayerBounds Bounds;
std::uint32_t Unknown18;
std::uint8_t Opacity;
std::uint8_t Visible;
std::uint8_t PreserveOpacity;
std::uint8_t Clipping;
std::uint8_t Unknown1C;
std::uint32_t Blending; // BlendingModes enum
};
Immediately after the LayerHeader
is a Serial Stream.
Note: Not all streams might be present depending on the type of layer the file is referencing. Streams such as
texp
andpeff
may not exist if the layer is a lineart layer or folder
lorg
std::uint32_t Unknown0;
std::uint32_t Unknown4;
name
Zero terminated string of the layer's name.
std::uint8_t LayerName[256];
pfid
Parent Set ID. If this layer is a child of a folder this will be a layer identifier of the parent container layer.
std::uint32_t ParentSetID;
plid
Parent Layer ID. If this layer is a child of another layer(ex, a mask-layer) this will be a layer identifier of the parent container layer.
std::uint32_t ParentLayerID;
lmfl
Only appears in mask layers
// 0b01 = Nonzero blending mode?
// 0b10 = Opacity is greater than 0
std::uint32_t Unknown0; // Bitmask, only the bottom two bits are used
fopn
Present only in a layer that is a Set/Folder.
A single bool
variable for if the folder is expanded within the layers panel or not
std::uint8_t Open;
texn
Name of the overlay-texture assigned to a layer. Ex: Watercolor A
Only appears in layers that have an overlay enabled
std::uint8_t TextureName[64]; // UTF16 string
texp
Options related to the overlay-texture
std::uint16_t TextureScale;
std::uint8_t TextureOpacity;
peff
Options related to the watercolor fringe assigned to a layer
std::uint8_t Enabled; // bool
std::uint8_t Opacity; // 100
std::uint8_t Width; // 1 - 15
vmrk
std::uint8_t Unknown0;
Immediately after the stream may be the contents of the layer. If the layer is a folder or set, there is no additional data. If the layer is a raster layer of pixels then specially formatted raster
data follows. If the layer is a linework layer, specifically formatted linework
data follows.
Sample layer file reading procedure
// Read header
LayerHeader CurHeader = LayerFile.Read<LayerHeader>(LayerHead);
// Read Serial Stream
std::uint32_t CurTag, CurTagSize;
CurTag = CurTagSize = 0;
char Name[256];
while( LayerFile.Read<std::uint32_t>(CurTag) && CurTag )
{
LayerFile.Read<std::uint32_t>(CurTagSize);
switch( CurTag )
{
case 'name':
{
LayerFile.Read<char[256]>(Name);
break;
}
// any other cases you care for
case 'pfid': // Parent folder ID
{
// ...
break;
}
default:
{
LayerFile.Seek(LayerFile.Tell() + CurTagSize);
break;
}
}
}
if( CurHeader.Type == LayerType::Layer )
{
// Read Raster data
}
else if( CurHeader.Type == LayerType::Linework )
{
// Read Linework data
}
Raster data is stored in a tiled format immediately after the header structure above. There is an array of (LayerWidth / 32) * (LayerHeight / 32)
8-bit boolean integer values stored before the compressed channel pixel data. Each boolean value within this BlockMap
determines if the appropriately positioned 32x32
tile of bitmap data contains pixel data that varies from pure black transparency. If a tile is active(1), its pixel data is stored as four or more streams of Run-Length-Encoding compressed data for each color channel for that 32x32
tile. If a tile is not active(0), the tile is to be filled with a 32x32
fully transparent block of pixels(0x00000000
for all pixels). If more than four streams exist, the extra streams may be safely ignored and skipped. Note that the RLE routine is the very same algorithm that Photoshop uses when compressing layer data and the same as the PackBits algorithm that apple uses.
RLE streams are prefixed with a 16-bit size integer for the amount of RLE stream bytes that follow. Compressed channel data will be at max 0x800
bytes. Decompressed data will be at most 0x1000
bytes. Use these as your buffer sizes when reading and decompressing in-place. Color data is stored with premultiplied alpha
and should be converted to straight
as soon as relavently needed. It is highly recommended to use SIMD intrinsics featured in C headers such as emmintrin.h
and tmmintrin.h
to speed up conversions and arithmetic upon pixel data. Internally Sai uses MMX
for all of its SIMD speedups so many structures already lend themselves to more modern SIMD speedups(SSE,AVX,etc). Pixel data is stored in BGRA order
- First, load in the array of
(LayerWidth / 32) * (LayerHeight / 32)
bytes immediately following the layer's Serial Stream asBlockMap
- Iterate both Y and X dimensions by
LayerHeight / 32
andLayerWidth / 32
times respectively
- Be sure to iterate the Y dimension first, then the X to ensure a row-by-row iteration.
- Access the the boolean at index
(LayerWidth/32) * Y + X
fromBlockMap
- If the boolean is true(1)
- Read a 16 bit integer
- If nonzero, read this amount of data, decompress it, and put this data into the correct
B
,G
,R
, orA
channel in order for however you're formatting your pixel data. Read another 16-bit integer and test for non-zero again in step one to get the next channel.- If there are more than 4 streams(channels) you can safely skip the extra RLE streams by this 16 bit integer amount in bytes by iterating again at step 2.
- I have yet to find out what the extra channels are but it is possibly "mip-map-like" data for different zoom levels to speed up certain calculations
- If zero, no more streams to read. Move on to the next tile by iterating at step 2.
- Access the the boolean at index
Here is a sample scratch-implementation I made using SIMD to shuffle channels into RGBA
format and convert from premultiplied alpha
to straight alpha
as well as
Routine for decompressing an RLE stream and placing resulting data into the appropriate interleaved 32bpp 8bpc channel index.
void RLEDecompress32(void* Destination, const std::uint8_t *Source, std::size_t SourceSize, std::size_t IntCount, std::size_t Channel)
{
std::uint8_t *Write = reinterpret_cast<std::uint8_t*>(Destination) + Channel;
std::size_t WriteCount = 0;
while( WriteCount < IntCount )
{
std::uint8_t Length = *Source++;
if( Length == 128 ) // No-op
{
}
else if( Length < 128 ) // Copy
{
// Copy the next Length+1 bytes
Length++;
WriteCount += Length;
while( Length )
{
*Write = *Source++;
Write += 4;
Length--;
}
}
else if( Length > 128 ) // Repeating byte
{
// Repeat next byte exactly "-Length + 1" times
Length ^= 0xFF;
Length += 2;
WriteCount += Length;
std::uint8_t Value = *Source++;
while( Length )
{
*Write = Value;
Write += 4;
Length--;
}
}
}
}
// Read BlockMap
// Do not use a vector<bool> as this is commonly implemented as a specialized vector type that does not implement individual bool values as bytes but rather as packed bits within a word
std::vector<std::uint8_t> BlockMap;
TileData.resize((LayerHead.Bounds.Width / 32) * (LayerHead.Bounds.Height / 32));
// Read Block Map
LayerFile.Read(BlockMap.data(), (LayerHead.Bounds.Width / 32) * (LayerHead.Bounds.Height / 32));
// the resulting raster image data for this layer, RGBA 32bpp interleaved
// Use a vector to ensure that tiles with no data are still initialized
// to #00000000
// Also note that the claim that SystemMax has made involving 16bit color depth
// may actually only be true at run-time. All raster data found in files are stored at
// 8bpc while only some run-time color arithmetic converts to 16-bit
std::vector<std::uint8_t> LayerImage;
LayerImage.resize(LayerHead.Bounds.Width * LayerHead.Bounds.Height * 4);
// iterate 32x32 tile chunks row by row
for( std::size_t y = 0; y < (LayerHead.Bounds.Height / 32); y++ )
{
for( std::size_t x = 0; x < (LayerHead.Bounds.Width / 32); x++ )
{
if( BlockMap[(LayerHead.Bounds.Width / 32) * y + x] ) // if tile is active
{
// Decompress Tile
std::array<std::uint8_t, 0x800> CompressedTile;
// Aligned memory for simd
alignas(sizeof(__m128i)) std::array<std::uint8_t, 0x1000> DecompressedTile;
std::uint8_t Channel = 0;
std::uint16_t Size = 0;
while( LayerFile.Read<std::uint16_t>(Size) ) // Get Current RLE stream size
{
LayerFile.Read(CompressedTile.data(), Size);
// decompress and place into the appropriate interleaved channel
RLEDecompress32(
DecompressedTile.data(),
CompressedTile.data(),
Size,
1024,
Channel
);
Channel++; // Move on to next channel
if( Channel >= 4 ) // skip all other channels besides the RGBA ones we care about
{
for( std::size_t i = 0; i < 4; i++ )
{
std::uint16_t Size = LayerFile.Read<std::uint16_t>();
LayerFile.Seek(LayerFile.Tell() + Size);
}
break;
}
}
// Current 32x32 tile within final image
std::uint32_t *ImageBlock = reinterpret_cast<std::uint32_t*>(LayerImage.data()) + (x * 32) + ((y * LayerHead.Bounds.Width) * 32);
for( std::size_t i = 0; i < (32 * 32) / 4; i++ ) // Process 4 pixels at a time
{
__m128i QuadPixel = _mm_load_si128(
reinterpret_cast<__m128i*>(DecompressedTile.data()) + i
);
// ABGR to ARGB, if you want.
// Do your swizzling here
QuadPixel = _mm_shuffle_epi8(
QuadPixel,
_mm_set_epi8(
15, 12, 13, 14,
11, 8, 9, 10,
7, 4, 5, 6,
3, 0, 1, 2)
);
/// Alpha is pre-multiplied, convert to straight
// Get Alpha into [0.0,1.0] range
__m128 Scale = _mm_div_ps(
_mm_cvtepi32_ps(
_mm_shuffle_epi8(
QuadPixel,
_mm_set_epi8(
-1, -1, -1, 15,
-1, -1, -1, 11,
-1, -1, -1, 7,
-1, -1, -1, 3
)
)
), _mm_set1_ps(255.0f));
// Normalize each channel into straight color
for( std::uint8_t i = 0; i < 3; i++ )
{
__m128i CurChannel = _mm_srli_epi32(QuadPixel, i * 8);
CurChannel = _mm_and_si128(CurChannel, _mm_set1_epi32(0xFF));
__m128 ChannelFloat = _mm_cvtepi32_ps(CurChannel);
ChannelFloat = _mm_div_ps(ChannelFloat, _mm_set1_ps(255.0));// [0,255] to [0,1]
ChannelFloat = _mm_div_ps(ChannelFloat, Scale);
ChannelFloat = _mm_mul_ps(ChannelFloat, _mm_set1_ps(255.0));// [0,1] to [0,255]
CurChannel = _mm_cvtps_epi32(ChannelFloat);
CurChannel = _mm_and_si128(CurChannel, _mm_set1_epi32(0xff));
CurChannel = _mm_slli_epi32(CurChannel, i * 8);
QuadPixel = _mm_andnot_si128(_mm_set1_epi32(0xFF << (i * 8)), QuadPixel);
QuadPixel = _mm_or_si128(QuadPixel, CurChannel);
}
// Write directly to final image
_mm_store_si128(
reinterpret_cast<__m128i*>(ImageBlock) + (i % 8) + ((i / 8) * (LayerHead.Bounds.Width / 4)),
QuadPixel
);
}
}
}
}
Mask layers consist of 16bpc grayscale pixels, stored in big endian. They can be read with the same procedure that raster
data uses.
This is a snippet with the current implementation of ReadRasterLayer
on Document.cpp
, but using int16_t
and a smaller Compress
and Decompressed
buffer instead:
std::unique_ptr<std::int16_t[]> ReadMaskLayer(
const sai::LayerHeader& LayerHeader, sai::VirtualFileEntry& LayerFile
)
{
const std::size_t TileSize = 32u;
const std::size_t LayerTilesX = LayerHeader.Bounds.Width / TileSize;
const std::size_t LayerTilesY = LayerHeader.Bounds.Height / TileSize;
const auto Index2D = [](std::size_t X, std::size_t Y, std::size_t Stride
) -> std::size_t { return X + (Y * Stride); };
// Do not use a std::vector<bool> as this is implemented as a specialized
// type that does not implement individual bool values as bytes, but rather
// as packed bits within a word.
std::unique_ptr<std::uint8_t[]> TileMap
= std::make_unique<std::uint8_t[]>(LayerTilesX * LayerTilesY);
LayerFile.Read(TileMap.get(), LayerTilesX * LayerTilesY);
std::unique_ptr<std::int16_t[]> LayerImage
= std::make_unique<std::int16_t[]>(
LayerHeader.Bounds.Width * LayerHeader.Bounds.Height
);
// 32 x 32 Tile of G8A8 pixels
std::array<std::uint8_t, 0x800> CompressedTile = {};
std::array<std::uint8_t, 0x800> DecompressedTile = {};
// Iterate 32x32 tile chunks row by row
for( std::size_t y = 0; y < LayerTilesY; ++y )
{
for( std::size_t x = 0; x < LayerTilesX; ++x )
{
// Process active Tiles
if( !TileMap[Index2D(x, y, LayerTilesX)] )
continue;
std::uint8_t CurChannel = 0;
std::uint16_t RLESize = 0;
// Iterate RLE streams for each channel
while( LayerFile.Read<std::uint16_t>(RLESize)
== sizeof(std::uint16_t) )
{
assert(RLESize <= CompressedTile.size());
if( LayerFile.Read(CompressedTile.data(), RLESize) != RLESize )
{
// Error reading RLE stream
break;
}
// Decompress and place into the appropriate interleaved channel
RLEDecompressStride(
DecompressedTile.data(), CompressedTile.data(),
sizeof(std::int16_t), 0x1000 / sizeof(std::uint32_t),
CurChannel
);
++CurChannel;
if( CurChannel == 2 )
{
break;
}
}
// Write 32x32 tile into final image
const std::int16_t* ImageSource
= reinterpret_cast<const std::int16_t*>(DecompressedTile.data()
);
// Current 32x32 tile within final image
std::int16_t* ImageDest
= LayerImage.get()
+ Index2D(x * TileSize, y * LayerHeader.Bounds.Width, TileSize);
for( std::size_t i = 0; i < (TileSize * TileSize); i++ )
{
std::int16_t CurPixel = ImageSource[i];
///
// Do any Per-Pixel processing you need to do here
///
ImageDest[Index2D(
i % TileSize, i / TileSize, LayerHeader.Bounds.Width
)] = CurPixel;
}
}
}
return LayerImage;
}
Both of this types use the same reading/writing procedure that mask layers, which means that they are probably related to greyscale/monochrome color formats ( although, is not clear if they are actually used at all ).
Todo
This is the key that we care for. Used to encrypt/decrypt all user-created files.
Decrypts .sai
files.
const std::uint32_t UserKey[256] =
{
0x9913D29E,0x83F58D3D,0xD0BE1526,0x86442EB7,0x7EC69BFB,0x89D75F64,0xFB51B239,0xFF097C56,
0xA206EF1E,0x973D668D,0xC383770D,0x1CB4CCEB,0x36F7108B,0x40336BCD,0x84D123BD,0xAFEF5DF3,
0x90326747,0xCBFFA8DD,0x25B94703,0xD7C5A4BA,0xE40A17A0,0xEADAE6F2,0x6B738250,0x76ECF24A,
0x6F2746CC,0x9BF95E24,0x1ECA68C5,0xE71C5929,0x7817E56C,0x2F99C471,0x395A32B9,0x61438343,
0x5E3E4F88,0x80A9332C,0x1879C69F,0x7A03D354,0x12E89720,0xF980448E,0x03643576,0x963C1D7B,
0xBBED01D6,0xC512A6B1,0x51CB492B,0x44BADEC9,0xB2D54BC1,0x4E7C2893,0x1531C9A3,0x43A32CA5,
0x55B25A87,0x70D9FA79,0xEF5B4AE3,0x8AE7F495,0x923A8505,0x1D92650C,0xC94A9A5C,0x27D4BB14,
0x1372A9F7,0x0C19A7FE,0x64FA1A53,0xF1A2EB6D,0x9FEB910F,0x4CE10C4E,0x20825601,0x7DFC98C4,
0xA046C808,0x8E90E7BE,0x601DE357,0xF360F37C,0x00CD6F77,0xCC6AB9D4,0x24CC4E78,0xAB1E0BFC,
0x6A8BC585,0xFD70ABF0,0xD4A75261,0x1ABF5834,0x45DCFE17,0x5F67E136,0x948FD915,0x65AD9EF5,
0x81AB20E9,0xD36EAF42,0x0F7F45C7,0x1BAE72D9,0xBE116AC6,0xDF58B4D5,0x3F0B960E,0xC2613F98,
0xB065F8B0,0x6259F975,0xC49AEE84,0x29718963,0x0B6D991D,0x09CF7A37,0x692A6DF8,0x67B68B02,
0x2E10DBC2,0x6C34E93C,0xA84B50A1,0xAC6FC0BB,0x5CA6184C,0x34E46183,0x42B379A9,0x79883AB6,
0x08750921,0x35AF2B19,0xF7AA886A,0x49F281D3,0xA1768059,0x14568CFD,0x8B3625F6,0x3E1B2D9D,
0xF60E14CE,0x1157270A,0xDB5C7EB3,0x738A0AFA,0x19C248E5,0x590CBD62,0x7B37C312,0xFC00B148,
0xD808CF07,0xD6BD1C82,0xBD50F1D8,0x91DEA3B8,0xFA86B340,0xF5DF2A80,0x9A7BEA6E,0x1720B8F1,
0xED94A56B,0xBF02BE28,0x0D419FA8,0x073B4DBC,0x829E3144,0x029F43E1,0x71E6D51F,0xA9381F09,
0x583075E0,0xE398D789,0xF0E31106,0x75073EB5,0x5704863E,0x6EF1043B,0xBC407F33,0x8DBCFB25,
0x886C8F22,0x5AF4DD7A,0x2CEACA35,0x8FC969DC,0x9DB8D6B4,0xC65EDC2F,0xE60F9316,0x0A84519A,
0x3A294011,0xDCF3063F,0x41621623,0x228CB75B,0x28E9D166,0xAE631B7F,0x06D8C267,0xDA693C94,
0x54A5E860,0x7C2170F4,0xF2E294CB,0x5B77A0F9,0xB91522A6,0xEC549500,0x10DD78A7,0x3823E458,
0x77D3635A,0x018E3069,0xE039D055,0xD5C341BF,0x9C2400EA,0x85C0A1D1,0x66059C86,0x0416FF1A,
0xE27E05C8,0xB19C4C2D,0xFE4DF58F,0xD2F0CE2A,0x32E013C0,0xEED637D7,0xE9FEC1E8,0xA4890DCA,
0xF4180313,0x7291738C,0xE1B053A2,0x9801267E,0x2DA15BDB,0xADC4DA4F,0xCF95D474,0xC0265781,
0x1F226CED,0xA7472952,0x3C5F0273,0xC152BA68,0xDD66F09B,0x93C7EDCF,0x4F147404,0x3193425D,
0x26B5768A,0x0E683B2E,0x952FDF30,0x2A6BAE46,0xA3559270,0xB781D897,0xEB4ECB51,0xDE49394D,
0x483F629C,0x2153845E,0xB40D64E2,0x47DB0ED0,0x302D8E4B,0x4BF8125F,0x2BD2B0AC,0x3DC836EC,
0xC7871965,0xB64C5CDE,0x9EA8BC27,0xD1853490,0x3B42EC6F,0x63A4FD91,0xAA289D18,0x4D2B1E49,
0xB8A060AD,0xB5F6C799,0x6D1F7D1C,0xBA8DAAE6,0xE51A0FC3,0xD94890E7,0x167DF6D2,0x879BCD41,
0x5096AC1B,0x05ACB5DA,0x375D24EE,0x7F2EB6AA,0xA535F738,0xCAD0AD10,0xF8456E3A,0x23FD5492,
0xB3745532,0x53C1A272,0x469DFCDF,0xE897BF7D,0xA6BBE2AE,0x68CE38AF,0x5D783D0B,0x524F21E4,
0x4A257B31,0xCE7A07B2,0x562CE045,0x33B708A4,0x8CEE8AEF,0xC8FB71FF,0x74E52FAB,0xCDB18796
};
Seems to only be used for the Notremoveme.ssd
file located in "C:\ProgramData\SYSTEMAX Software Development\SAI"
Appears to contain log data similar to sai.ssd
const std::uint32_t NotRemoveMeKey[256] =
{
0xA0C62B54,0x0374CB94,0xB3A53F76,0x5B772C6B,0xF2B92931,0x80F923A9,0x7A22EF7A,0x216C7582,
0xEDFF8B71,0x8B0C6642,0xAF81AD2F,0x8E095A62,0x02926C0C,0xDD2F56B9,0xA3614155,0xF9AED6E4,
0x079C3E5E,0xE6D9E1FD,0x256F165C,0x77280767,0x5D2037A1,0x3019B3CE,0xFC13CC15,0xF457C85F,
0x728DF4E9,0x4405AA18,0x2AE0B950,0xE847316F,0xD69FA172,0x62F658E2,0xB0F21F89,0x8AFB852E,
0x1A3E924A,0xDBAD0B48,0x88ECBD5A,0xC53FC908,0x81251757,0x57D53685,0x73F463A3,0x048F4B58,
0xC36A46AC,0x9A8B6FBD,0x35DC9DC1,0xF76EABF5,0x9280D935,0xBFCC93FB,0x4B2BCA7D,0x60861DFC,
0x7C548877,0x2EA46821,0x7136998F,0x5AD45EDF,0x019BA6EF,0x6FC598C7,0x1DF383EC,0x39BAC06D,
0x5C3A5B1F,0x7827FB39,0x27FCA953,0x8601E843,0x6C429623,0xBA5DC127,0xCE659075,0x48291378,
0x5EDA6B5B,0xE355AC99,0xCF8C704D,0x965E6A29,0xF5035103,0x20582702,0x1B7909DB,0xCA974452,
0x7DB20E30,0x2807326C,0x2DF56D0E,0x084E9C41,0xA42DE39C,0x9170A5C3,0x9DB4F95D,0x53CA2068,
0x3488FC6E,0xD1BB7AE8,0xC61F81C5,0x310857E5,0xEF1694EE,0xF63067B1,0x3E621B8B,0x22523BFF,
0x0D37A4BA,0xCB83BECA,0x9BE78691,0xB7D84E2C,0x45A676DD,0x1F31F636,0x7FAB97C6,0x3CA15F33,
0xFA6DB6FE,0x67DD72DC,0x6B8948FA,0x9849FF4B,0xBE452E79,0x38AF6E7F,0x8FE211A7,0x941728B4,
0x63217749,0x70EF1280,0x13A9F201,0xACDB14A2,0x1184E73A,0x337E87B5,0xB6008EB7,0xC868C43C,
0x85F7DC83,0xD35AD519,0xF87310ED,0xA7C0D29B,0x361D2DCF,0xC1D27C3F,0x9C78DFE0,0x2C4FD8C4,
0x05357D9D,0x2B398964,0x182AC610,0xFD4A3873,0xE71E6416,0x842C4A05,0x5946F70F,0xB95FA366,
0x1C0B71CB,0x50CEFA06,0xAB9DC211,0x659ABCAE,0xD2E17FE7,0x581A0365,0xA61BE0B0,0xD460B084,
0xE21C5CF9,0x87B1D460,0x4DF8CF04,0x4C1573EA,0xCD967432,0xD58EBA12,0x5F2E9A3B,0x6A9955EB,
0x55A391AF,0xEBC1EED5,0xB59E8C7C,0x1E825946,0xAA18A04E,0x6891EDF3,0x663C542D,0xC459D37E,
0xC06453BC,0x460D223E,0x1690F8DE,0xC97580F7,0xA1F08D4F,0x56DE4381,0xEE06B5E3,0xC2FA05D1,
0x3794B488,0xEACD428E,0x7B2362C2,0xE97FDE9F,0xBB4C60D2,0xE4B3E2AB,0x74C93909,0x76AA2FDA,
0x9F049B7B,0x93BCDA8A,0x51BEC790,0x0FD6E4CC,0x8972E6AD,0xBCA70F40,0x405C2469,0x10673486,
0xBD104C97,0x49381E0D,0x063B456A,0x23D02634,0x43ACEC9E,0xE50E49F8,0x197DBF1B,0x8DF1BB9A,
0xB46B1CA6,0xD7E895A5,0xCC51A217,0xE1C2F196,0xDEB533C9,0x24FDC58D,0x32850822,0x12DF4DA8,
0x90BD3500,0x97C7F320,0xDA3450F4,0x2F534059,0xDC7B3D63,0x95B6CD98,0x09BF19D6,0xA5D15DBF,
0x42E47851,0xF07A021E,0x9ECB2A3D,0xE0C39F38,0x99714F95,0x3A5BEA4C,0xB2C4DD25,0xB13D47C0,
0xAD418A0B,0x6DEAB81C,0x83EE25F2,0x3B26AE47,0xA8B018D3,0xFF76E5F1,0xA2ED0461,0x26119ED8,
0x61EB0A74,0x15A2B187,0x4A93CE2A,0x7943A707,0x29E5B744,0x4E14F02B,0x0A698424,0xD9A03AE6,
0xEC87D7C8,0xA94021B8,0x3D95D1CD,0x6E2415BE,0x52E3F592,0x64A83CD9,0x8263C31D,0x41B87EB6,
0x8C50FD1A,0x47C80CD7,0xD844008C,0xB812E9AA,0x0B983013,0xFB7C520A,0x4F66FEBB,0x17E982D0,
0x00FE6914,0xFE0FD028,0x0C328F93,0x75021AF6,0x3FE6AFB2,0x7E330DE1,0xDF8ADB45,0x14D37B37,
0xD04D06A4,0x694B0156,0x0ECF6170,0xC756EBF0,0xF1B76526,0xF348A8B3,0xAE0A79A0,0x54D7B2D4
};
Used for thumbnail files located in "C:\ProgramData\SYSTEMAX Software Development\SAI\thumbnail"
Thumbnail filenames use printf pattern "%08x.ssd"
.
Named LocalState
as it describes an active user context.
const std::uint32_t LocalStateKey[256] =
{
0x021CF107,0xE9253648,0x8AFBA619,0x8CF31842,0xBF40F860,0xA672F03E,0xFA2756AC,0x927B2E7E,
0x1E37D3C4,0x7C3A0524,0x4F284D1B,0xD8A31E9D,0xBA73B6E6,0xF399710D,0xBD8B1937,0x70FFE130,
0x056DAA4A,0xDC509CA1,0x07358DFF,0xDF30A2DC,0x67E7349F,0x49532C31,0x2393EBAA,0xE54DF202,
0x3A2C7EC9,0x98AB13EF,0x7FA52975,0x83E4792E,0x7485DA08,0x4A1823A8,0x77812011,0x8710BB89,
0x9B4E0C68,0x64125D8E,0x5F174A0E,0x33EA50E7,0xA5E168B0,0x1BD9B944,0x6D7D8FE0,0xEE66B84C,
0xF0DB530C,0xF8B06B72,0x97ED7DF8,0x126E0122,0x364BED23,0xA103B75C,0x3BC844FA,0xD0946501,
0x4E2F70F1,0x79A6F413,0x60B9E977,0xC1582F10,0x759B286A,0xE723EEF5,0x8BAC4B39,0xB074B188,
0xCC528E64,0x698700EE,0x44F9E5BB,0x7E336153,0xE2413AFD,0x91DCE2BE,0xFDCE9EC1,0xCAB2DE4F,
0x46C5A486,0xA0D630DB,0x1FCD5FCA,0xEA110891,0x3F20C6F9,0xE8F1B25D,0x6EFD10C8,0x889027AF,
0xF284AF3F,0x89EE9A61,0x58AF1421,0xE41B9269,0x260C6D71,0x5079D96E,0xD959E465,0x519CD72C,
0x73B64F5A,0x40BE5535,0x78386CBC,0x0A1A02CF,0xDBC126B6,0xAD02BC8D,0x22A85BC5,0xA28ABEC3,
0x5C643952,0xE35BC9AD,0xCBDACA63,0x4CA076A4,0x4B6121CB,0x9500BF7D,0x6F8E32BF,0xC06587E5,
0x21FAEF46,0x9C2AD2F6,0x7691D4A2,0xB13E4687,0xC7460AD6,0xDDFE54D5,0x81F516F3,0xC60D7438,
0xB9CB3BC7,0xC4770D94,0xF4571240,0x06862A50,0x30D343D3,0x5ACF52B2,0xACF4E68A,0x0FC2A59B,
0xB70AEACD,0x53AA5E80,0xCF624E8F,0xF1214CEB,0x936072DF,0x62193F18,0xF5491CDA,0x5D476958,
0xDA7A852D,0x5B053E12,0xC5A9F6D0,0xABD4A7D1,0xD25E6E82,0xA4D17314,0x2E148C4E,0x6B9F6399,
0xBC26DB47,0x8296DDCE,0x3E71D616,0x350E4083,0x2063F503,0x167833F2,0x115CDC5E,0x4208E715,
0x03A49B66,0x43A724BA,0xA3B71B8C,0x107584AE,0xC24AE0C6,0xB3FC6273,0x280F3795,0x1392C5D4,
0xD5BAC762,0xB46B5A3B,0xC9480B8B,0xC39783FC,0x17F2935B,0x9DB482F4,0xA7E9CC09,0x553F4734,
0x8DB5C3A3,0x7195EC7A,0xA8518A9A,0x0CE6CB2A,0x14D50976,0x99C077A5,0x012E1733,0x94EC3D7C,
0x3D825805,0x0E80A920,0x1D39D1AB,0xFCD85126,0x3C7F3C79,0x7A43780B,0xB26815D9,0xAF1F7F1C,
0xBB8D7C81,0xAAE5250F,0x34BC670A,0x1929C8D2,0xD6AE9FC0,0x1AE07506,0x416F3155,0x9EB38698,
0x8F22CF29,0x04E8065F,0xE07CFBDE,0x2AEF90E8,0x6CAD049C,0x4DC3A8CC,0x597E3596,0x08562B92,
0x52A21D6F,0xB6C9881D,0xFBD75784,0xF613FC32,0x54C6F757,0x66E2D57B,0xCD69FE9E,0x478CA13D,
0x2F5F6428,0x8E55913C,0xF9091185,0x0089E8B3,0x1C6A48BD,0x3844946D,0x24CC8B6B,0x6524AC2B,
0xD1F6A0F0,0x32980E51,0x8634CE17,0xED67417F,0x250BAEB9,0x84D2FD1A,0xEC6C4593,0x29D0C0B1,
0xEBDF42A9,0x0D3DCD45,0x72BF963A,0x27F0B590,0x159D5978,0x3104ABD7,0x903B1F27,0x9F886A56,
0x80540FA6,0x18F8AD1F,0xEF5A9870,0x85016FC2,0xC8362D41,0x6376C497,0xE1A15C67,0x6ABD806C,
0x569AC1E2,0xFE5D1AF7,0x61CADF59,0xCE063874,0xD4F722DD,0x37DEC2EC,0xAE70BDEA,0x0B2D99B4,
0x39B895FE,0x091E9DFB,0xA9150754,0x7D1D7A36,0x9A07B41E,0x5E8FE3B5,0xD34503A0,0xBE2BFAB7,
0x5742D0A7,0x48DDBA25,0x7BE3604D,0x2D4C66E9,0xB831FFB8,0xF7BBA343,0x451697E4,0x2C4FD84B,
0x96B17B00,0xB5C789E3,0xFFEBF9ED,0xD7C4B349,0xDE3281D8,0x689E4904,0xE683F32F,0x2B3CB0E1
};
Used only for sai.ssd
Handled the same as user-files but with a different block size of 1024
and Table-blocks
indexes at every multiple of 128
.
sai.ssd
seems to have multiple log files stored with symbolic headers:
- "++FSIF logfile++"
- Seems to be related to file-security and encryption
- "++VFS logfile++"
- Everything related to the virtual file system
- "++SCDF logfile++"
- Unknown
const std::uint32_t SystemKey[256] =
{
0x724FB987,0x4A3E70BE,0xCA549C50,0x34E263E1,0x2D5ED2FF,0x127F0E11,0x58A42B78,0x5F6D14AE,
0x7E2F745D,0xC3450384,0xCFBB15DE,0xDF0A6D8A,0xEF2545F3,0x6D8919DB,0xBC413C94,0xCCB0A198,
0xE42DBBD2,0x361C0B8C,0x8359731F,0x13D61E9F,0x7505F7CE,0x271D7957,0x429C0699,0xD84EC85F,
0x953391DD,0xB25DE567,0xC1BA2F97,0x2309B605,0x69A134D1,0x14A092F2,0x681500EF,0xB90148A7,
0x01AF398B,0x16FD5168,0x9E572161,0x0F7405E3,0x56AC576D,0xF275A349,0x1E8120C0,0x4BF64E3A,
0x5A90E85E,0xD27BC4F1,0x3BD2FFB1,0xD6B40FDC,0x26EC61CF,0xF744AD3F,0xCDE7C548,0x8AFFE60A,
0xE382CA47,0x87DA3E1B,0x8FA3DB36,0x5737C7E0,0xACD8CC17,0xD0CC3B66,0xD93D776B,0x37E5BE2B,
0xD38A1129,0x037E81D0,0x15B15072,0xA6493052,0x35BCD4B9,0xC4538D32,0xEC66C1D5,0xA20DF513,
0x5524EB75,0x92C10488,0xDA03D9FD,0x65168F4B,0x1902BA24,0x7439FA7D,0x1D8CB46F,0xFBC39389,
0xC5DF6A58,0x89E8FB00,0x50DBE0A1,0xAAE98AF8,0x6A7C6C9C,0x7712D6EC,0x4030D0CD,0x6052B585,
0x6132AA77,0xEB4A38C3,0x673AB1E6,0x1C3C07C6,0x91EA2C76,0x7A4C7EA0,0x10B3DCFC,0xBE7DF402,
0x2817D87A,0x25632264,0xBD8D02B0,0xF6D0F8A8,0xB1ED3AF0,0xE6C4F1CA,0x99E028B5,0xE5D48674,
0x09CF47B8,0x9D6EAF0E,0x0A721AFE,0xB6109E54,0x8D642344,0x9FEFC27C,0xF0CA520F,0x2C6BDA7E,
0x2E9DB06A,0x97DEFC2E,0x53C5F0EE,0xAD4B8C60,0xE9F36696,0xA8C68907,0x70B70A20,0x3D9F82AA,
0x7604A595,0x441A563B,0x39193D4A,0x33BF1DC7,0x31B283FB,0xA399F25B,0x642CE39E,0xF9E3B204,
0x79A87534,0x5DBE2943,0x9813E93E,0x47864AD6,0xD420D1BF,0x24A6C986,0xFE386EF7,0xD1B65AB7,
0x3A96BF2F,0x006FE1AB,0x22938E90,0x78FE7A40,0x5CE1319B,0x46F5EEF5,0xBB064BE4,0xB7271C22,
0xC0225D21,0xFA145B10,0x7C58BC33,0xF84654C2,0xEEF4691E,0x021BEC16,0xE16C1737,0x1BCB2603,
0x48A2954D,0xDD56A8FA,0xB8C8A48D,0x5277590B,0x1194E7A9,0x590F42B4,0x7B97C0D8,0x7142B714,
0xAEDD6BC8,0xBA116212,0x6B0E642C,0xF42ABDC5,0x6E76AC81,0xBF348819,0xCB790C59,0xDC6718AD,
0x80471230,0x84DC985C,0x2AEE32C1,0x4D35964F,0x0C6894AC,0x3EF2CDE5,0xB59B37A5,0x9BC9729D,
0x186A41AF,0xEA98A970,0x21F8A291,0x5487E2C9,0xE05F3F42,0xA523B86E,0x8C1E4062,0xA962F6CB,
0x0D4816E8,0x9A4DF92D,0x20439DCC,0xA0713645,0x43506FE9,0xC2EB4651,0xB4780D6C,0xAFC29B28,
0x1FCE5FD4,0x9C7385D3,0xCE00E463,0x38CD997F,0x452933DA,0xC9F7DEBA,0x0840A093,0xDB287B41,
0x90E48479,0x66FC6709,0x6C884C65,0x3FB56082,0xF5B87123,0xED367D1D,0x6F0C44F9,0x8270DD38,
0x0E314F83,0x1AE69F35,0xD5A51FB3,0xA761A671,0x850B4DED,0x06AE0892,0x5EAA2A06,0xC7FA80F6,
0xB0692D4E,0x81657F8F,0x948B0980,0xB3D97C01,0xFC80C3EA,0xFF9E53A4,0x30BD784C,0xF3AD970C,
0xA12E9A31,0x04D37646,0x072655A3,0xE8D5F353,0x4CA98BDF,0x7391FE56,0x7D5BEDA6,0x2BD7650D,
0x862B5C73,0x8B60A726,0x7F8ECB3C,0x517A49B6,0xD7B9CF5A,0x6308D5BC,0x0B3F68D7,0x62A7EA15,
0xC65AFD3D,0xAB8525B2,0xA451B308,0xE7C7AB18,0x88F91369,0x1783279A,0x4F95DF2A,0x41F158BD,
0xC8D1CEBB,0x325CD3E2,0xF1928739,0x9355AE8E,0x2FC05EC4,0x4E0735E7,0xDE3B10D9,0x8E18C61A,
0xE29AEF25,0x4984D7A2,0x051F247B,0x29AB9055,0xFD2101F4,0x96FB2E1C,0x5BF04327,0x3C8F1BEB
};