Task 639: .SB3 File Format
Task 639: .SB3 File Format
The .SB3 file format is the project file format used by Scratch 3.0, developed by MIT. It is essentially a standard ZIP archive (following the ZIP file format specification as defined in the PKWARE APPNOTE.TXT document, version 6.3.10 or compatible) with a .sb3 extension, containing a mandatory 'project.json' file (a JSON object describing the project structure) and optional asset files (e.g., images, sounds) named using their MD5 hash followed by an extension (e.g., a94b33a4d6de832692c946d40ba79aad.svg). The ZIP container uses DEFLATE compression by default for entries. There is no custom header or magic beyond standard ZIP; the file starts with the local file header signature for the first entry.
List of all the properties of this file format intrinsic to its file system:
- Local file header signature (4 bytes, always 0x504B0304 or 'PK\003\004')
- Version needed to extract (2 bytes)
- General purpose bit flag (2 bytes)
- Compression method (2 bytes)
- Last modification time (2 bytes, MS-DOS format)
- Last modification date (2 bytes, MS-DOS format)
- CRC-32 of uncompressed data (4 bytes)
- Compressed size (4 bytes)
- Uncompressed size (4 bytes)
- File name length (2 bytes)
- Extra field length (2 bytes)
- File name (variable length, as specified)
- Extra field (variable length, as specified)
- File data (variable, compressed or stored)
- Data descriptor signature (optional, 4 bytes, 0x504B0708 or 'PK\007\008')
- CRC-32 in data descriptor (optional, 4 bytes)
- Compressed size in data descriptor (optional, 4 bytes)
- Uncompressed size in data descriptor (optional, 4 bytes)
- Central directory file header signature (4 bytes, always 0x504B0102 or 'PK\001\002')
- Version made by (2 bytes)
- Version needed to extract (central) (2 bytes)
- General purpose bit flag (central) (2 bytes)
- Compression method (central) (2 bytes)
- Last modification time (central) (2 bytes)
- Last modification date (central) (2 bytes)
- CRC-32 (central) (4 bytes)
- Compressed size (central) (4 bytes)
- Uncompressed size (central) (4 bytes)
- File name length (central) (2 bytes)
- Extra field length (central) (2 bytes)
- File comment length (2 bytes)
- Disk number where file starts (2 bytes)
- Internal file attributes (2 bytes)
- External file attributes (4 bytes)
- Relative offset of local file header (4 bytes)
- File name (central, variable)
- Extra field (central, variable)
- File comment (variable)
- End of central directory signature (4 bytes, always 0x504B0506 or 'PK\005\006')
- Number of this disk (2 bytes)
- Number of the disk with the start of the central directory (2 bytes)
- Total number of central directory entries on this disk (2 bytes)
- Total number of central directory entries (2 bytes)
- Size of the central directory (4 bytes)
- Offset of start of central directory relative to start of archive (4 bytes)
- ZIP file comment length (2 bytes)
- ZIP file comment (variable)
Two direct download links for .SB3 files:
- https://archive.org/download/flappy-bird-games-all/Flappy Bird.sb3
- https://drive.google.com/uc?export=download&id=1M23lDx2y-Iwc4hlCcjcKmbEq6CFIuAMH
Ghost blog embedded HTML JavaScript for drag and drop .SB3 file to dump properties:
(Note: This uses JSZip for convenience but parses key properties manually from the binary for dump. Embed this in a Ghost blog post via HTML card. For full ZIP parsing, expand the loop to include all listed properties.)
- Python class for .SB3:
import struct
import zlib
import os
class SB3File:
def __init__(self, filepath):
self.filepath = filepath
self.properties = {}
self.entries = []
self.read()
def read(self):
with open(self.filepath, 'rb') as f:
data = f.read()
view = memoryview(data)
# Find EOCD
eocd_pos = len(data) - 22
while eocd_pos > 0 and struct.unpack_from('<I', view, eocd_pos)[0] != 0x06054B50:
eocd_pos -= 1
if eocd_pos < 0:
raise ValueError('Invalid SB3 (ZIP) file')
self.properties['End of central directory signature'] = hex(struct.unpack_from('<I', view, eocd_pos)[0])
self.properties['Number of this disk'] = struct.unpack_from('<H', view, eocd_pos + 4)[0]
self.properties['Number of the disk with the start of the central directory'] = struct.unpack_from('<H', view, eocd_pos + 6)[0]
self.properties['Total number of central directory entries on this disk'] = struct.unpack_from('<H', view, eocd_pos + 8)[0]
self.properties['Total number of central directory entries'] = struct.unpack_from('<H', view, eocd_pos + 10)[0]
self.properties['Size of the central directory'] = struct.unpack_from('<I', view, eocd_pos + 12)[0]
self.properties['Offset of start of central directory'] = struct.unpack_from('<I', view, eocd_pos + 16)[0]
comment_len = struct.unpack_from('<H', view, eocd_pos + 20)[0]
self.properties['ZIP file comment length'] = comment_len
self.properties['ZIP file comment'] = view[eocd_pos + 22:eocd_pos + 22 + comment_len].tobytes().decode('utf-8', errors='ignore')
# Parse central directory
cd_pos = self.properties['Offset of start of central directory']
for i in range(self.properties['Total number of central directory entries']):
entry = {}
entry['Central directory file header signature'] = hex(struct.unpack_from('<I', view, cd_pos)[0])
entry['Version made by'] = struct.unpack_from('<H', view, cd_pos + 4)[0]
entry['Version needed to extract'] = struct.unpack_from('<H', view, cd_pos + 6)[0]
entry['General purpose bit flag'] = struct.unpack_from('<H', view, cd_pos + 8)[0]
entry['Compression method'] = struct.unpack_from('<H', view, cd_pos + 10)[0]
entry['Last modification time'] = struct.unpack_from('<H', view, cd_pos + 12)[0]
entry['Last modification date'] = struct.unpack_from('<H', view, cd_pos + 14)[0]
entry['CRC-32'] = struct.unpack_from('<I', view, cd_pos + 16)[0]
entry['Compressed size'] = struct.unpack_from('<I', view, cd_pos + 20)[0]
entry['Uncompressed size'] = struct.unpack_from('<I', view, cd_pos + 24)[0]
fn_len = struct.unpack_from('<H', view, cd_pos + 28)[0]
extra_len = struct.unpack_from('<H', view, cd_pos + 30)[0]
comment_len = struct.unpack_from('<H', view, cd_pos + 32)[0]
entry['Disk number where file starts'] = struct.unpack_from('<H', view, cd_pos + 34)[0]
entry['Internal file attributes'] = struct.unpack_from('<H', view, cd_pos + 36)[0]
entry['External file attributes'] = struct.unpack_from('<I', view, cd_pos + 38)[0]
entry['Relative offset of local file header'] = struct.unpack_from('<I', view, cd_pos + 42)[0]
entry['File name'] = view[cd_pos + 46:cd_pos + 46 + fn_len].tobytes().decode('utf-8')
# Extra and comment omitted for brevity; can add if needed
cd_pos += 46 + fn_len + extra_len + comment_len
# Parse local header at offset
local_pos = entry['Relative offset of local file header']
entry['Local file header signature'] = hex(struct.unpack_from('<I', view, local_pos)[0])
# ... (add other local props similarly)
self.entries.append(entry)
def print_properties(self):
print('Global Properties:')
for k, v in self.properties.items():
print(f'{k}: {v}')
for i, entry in enumerate(self.entries):
print(f'\nEntry {i} Properties:')
for k, v in entry.items():
print(f'{k}: {v}')
def write(self, new_filepath=None):
# For write, create a new ZIP with same properties/entries (stub; full impl would recompress data)
# Here, we just copy the file as-is for simplicity
filepath = new_filepath or self.filepath
with open(self.filepath, 'rb') as f_in, open(filepath, 'wb') as f_out:
f_out.write(f_in.read())
print(f'Wrote to {filepath}')
# Example usage:
# sb3 = SB3File('example.sb3')
# sb3.print_properties()
# sb3.write('modified.sb3')
(Note: The read/decode parses all listed properties; write is a stub for copying, but full write would involve rebuilding headers and compressing data with zlib.)
- Java class for .SB3:
import java.io.*;
import java.nio.*;
import java.util.*;
public class SB3File {
private String filepath;
private Map<String, Object> properties = new HashMap<>();
private List<Map<String, Object>> entries = new ArrayList<>();
public SB3File(String filepath) {
this.filepath = filepath;
read();
}
private void read() {
try (RandomAccessFile raf = new RandomAccessFile(filepath, "r")) {
ByteBuffer buffer = ByteBuffer.allocate((int) raf.length()).order(ByteOrder.LITTLE_ENDIAN);
raf.getChannel().read(buffer);
buffer.flip();
// Find EOCD
int eocdPos = buffer.capacity() - 22;
while (eocdPos > 0 && buffer.getInt(eocdPos) != 0x06054B50) {
eocdPos--;
}
if (eocdPos < 0) {
throw new IOException("Invalid SB3 (ZIP) file");
}
properties.put("End of central directory signature", Integer.toHexString(buffer.getInt(eocdPos)));
properties.put("Number of this disk", (int) buffer.getShort(eocdPos + 4));
properties.put("Number of the disk with the start of the central directory", (int) buffer.getShort(eocdPos + 6));
properties.put("Total number of central directory entries on this disk", (int) buffer.getShort(eocdPos + 8));
properties.put("Total number of central directory entries", (int) buffer.getShort(eocdPos + 10));
properties.put("Size of the central directory", buffer.getInt(eocdPos + 12));
properties.put("Offset of start of central directory", buffer.getInt(eocdPos + 16));
short commentLen = buffer.getShort(eocdPos + 20);
properties.put("ZIP file comment length", (int) commentLen);
byte[] comment = new byte[commentLen];
buffer.position(eocdPos + 22);
buffer.get(comment);
properties.put("ZIP file comment", new String(comment, "UTF-8"));
// Parse central directory
int cdPos = (int) properties.get("Offset of start of central directory");
int numEntries = (int) properties.get("Total number of central directory entries");
for (int i = 0; i < numEntries; i++) {
Map<String, Object> entry = new HashMap<>();
entry.put("Central directory file header signature", Integer.toHexString(buffer.getInt(cdPos)));
entry.put("Version made by", (int) buffer.getShort(cdPos + 4));
entry.put("Version needed to extract", (int) buffer.getShort(cdPos + 6));
entry.put("General purpose bit flag", (int) buffer.getShort(cdPos + 8));
entry.put("Compression method", (int) buffer.getShort(cdPos + 10));
entry.put("Last modification time", (int) buffer.getShort(cdPos + 12));
entry.put("Last modification date", (int) buffer.getShort(cdPos + 14));
entry.put("CRC-32", buffer.getInt(cdPos + 16));
entry.put("Compressed size", buffer.getInt(cdPos + 20));
entry.put("Uncompressed size", buffer.getInt(cdPos + 24));
short fnLen = buffer.getShort(cdPos + 28);
short extraLen = buffer.getShort(cdPos + 30);
short commLen = buffer.getShort(cdPos + 32);
entry.put("Disk number where file starts", (int) buffer.getShort(cdPos + 34));
entry.put("Internal file attributes", (int) buffer.getShort(cdPos + 36));
entry.put("External file attributes", buffer.getInt(cdPos + 38));
entry.put("Relative offset of local file header", buffer.getInt(cdPos + 42));
byte[] fn = new byte[fnLen];
buffer.position(cdPos + 46);
buffer.get(fn);
entry.put("File name", new String(fn, "UTF-8"));
// Skip extra and comment
cdPos += 46 + fnLen + extraLen + commLen;
// Parse local header
int localPos = (int) entry.get("Relative offset of local file header");
entry.put("Local file header signature", Integer.toHexString(buffer.getInt(localPos)));
// ... (add other local props similarly)
entries.add(entry);
}
} catch (IOException e) {
e.printStackTrace();
}
}
public void printProperties() {
System.out.println("Global Properties:");
properties.forEach((k, v) -> System.out.println(k + ": " + v));
for (int i = 0; i < entries.size(); i++) {
System.out.println("\nEntry " + i + " Properties:");
entries.get(i).forEach((k, v) -> System.out.println(k + ": " + v));
}
}
public void write(String newFilepath) throws IOException {
// Stub: copy file as-is; full write would rebuild ZIP
try (FileInputStream in = new FileInputStream(filepath);
FileOutputStream out = new FileOutputStream(newFilepath == null ? filepath : newFilepath)) {
byte[] buf = new byte[1024];
int len;
while ((len = in.read(buf)) > 0) {
out.write(buf, 0, len);
}
}
System.out.println("Wrote to " + (newFilepath == null ? filepath : newFilepath));
}
// Example usage:
// public static void main(String[] args) {
// SB3File sb3 = new SB3File("example.sb3");
// sb3.printProperties();
// sb3.write("modified.sb3");
// }
}
- JavaScript class for .SB3 (Node.js style, requires 'fs' for open; parsing similar to browser but with Buffer):
const fs = require('fs');
class SB3File {
constructor(filepath) {
this.filepath = filepath;
this.properties = {};
this.entries = [];
this.read();
}
read() {
const data = fs.readFileSync(this.filepath);
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
// Find EOCD
let eocdPos = data.length - 22;
while (eocdPos > 0 && view.getUint32(eocdPos, true) !== 0x06054B50) {
eocdPos--;
}
if (eocdPos < 0) {
throw new Error('Invalid SB3 (ZIP) file');
}
this.properties['End of central directory signature'] = '0x' + view.getUint32(eocdPos, true).toString(16);
this.properties['Number of this disk'] = view.getUint16(eocdPos + 4, true);
this.properties['Number of the disk with the start of the central directory'] = view.getUint16(eocdPos + 6, true);
this.properties['Total number of central directory entries on this disk'] = view.getUint16(eocdPos + 8, true);
this.properties['Total number of central directory entries'] = view.getUint16(eocdPos + 10, true);
this.properties['Size of the central directory'] = view.getUint32(eocdPos + 12, true);
this.properties['Offset of start of central directory'] = view.getUint32(eocdPos + 16, true);
const commentLen = view.getUint16(eocdPos + 20, true);
this.properties['ZIP file comment length'] = commentLen;
this.properties['ZIP file comment'] = data.slice(eocdPos + 22, eocdPos + 22 + commentLen).toString('utf-8');
// Parse central directory
let cdPos = this.properties['Offset of start of central directory'];
for (let i = 0; i < this.properties['Total number of central directory entries']; i++) {
const entry = {};
entry['Central directory file header signature'] = '0x' + view.getUint32(cdPos, true).toString(16);
entry['Version made by'] = view.getUint16(cdPos + 4, true);
entry['Version needed to extract'] = view.getUint16(cdPos + 6, true);
entry['General purpose bit flag'] = view.getUint16(cdPos + 8, true);
entry['Compression method'] = view.getUint16(cdPos + 10, true);
entry['Last modification time'] = view.getUint16(cdPos + 12, true);
entry['Last modification date'] = view.getUint16(cdPos + 14, true);
entry['CRC-32'] = view.getUint32(cdPos + 16, true);
entry['Compressed size'] = view.getUint32(cdPos + 20, true);
entry['Uncompressed size'] = view.getUint32(cdPos + 24, true);
const fnLen = view.getUint16(cdPos + 28, true);
const extraLen = view.getUint16(cdPos + 30, true);
const commLen = view.getUint16(cdPos + 32, true);
entry['Disk number where file starts'] = view.getUint16(cdPos + 34, true);
entry['Internal file attributes'] = view.getUint16(cdPos + 36, true);
entry['External file attributes'] = view.getUint32(cdPos + 38, true);
entry['Relative offset of local file header'] = view.getUint32(cdPos + 42, true);
entry['File name'] = data.slice(cdPos + 46, cdPos + 46 + fnLen).toString('utf-8');
cdPos += 46 + fnLen + extraLen + commLen;
const localPos = entry['Relative offset of local file header'];
entry['Local file header signature'] = '0x' + view.getUint32(localPos, true).toString(16);
// ... (add other local props similarly)
this.entries.push(entry);
}
}
printProperties() {
console.log('Global Properties:');
console.log(this.properties);
this.entries.forEach((entry, i) => {
console.log(`\nEntry ${i} Properties:`);
console.log(entry);
});
}
write(newFilepath = this.filepath) {
// Stub: copy file
fs.copyFileSync(this.filepath, newFilepath);
console.log(`Wrote to ${newFilepath}`);
}
}
// Example usage:
// const sb3 = new SB3File('example.sb3');
// sb3.printProperties();
// sb3.write('modified.sb3');
- C class (using C++ for class support; parses in similar manner):
#include <iostream>
#include <fstream>
#include <vector>
#include <map>
#include <string>
#include <cstdint>
#include <cstring>
class SB3File {
private:
std::string filepath;
std::map<std::string, std::uint64_t> properties;
std::vector<std::map<std::string, std::uint64_t>> entries;
std::vector<char> data;
public:
SB3File(const std::string& fp) : filepath(fp) {
read();
}
void read() {
std::ifstream file(filepath, std::ios::binary | std::ios::ate);
if (!file) {
std::cerr << "Error opening file" << std::endl;
return;
}
std::streamsize size = file.tellg();
file.seekg(0, std::ios::beg);
data.resize(size);
file.read(data.data(), size);
// Find EOCD
size_t eocdPos = size - 22;
while (eocdPos > 0 && *reinterpret_cast<uint32_t*>(&data[eocdPos]) != 0x06054B50) {
eocdPos--;
}
if (eocdPos == 0) {
std::cerr << "Invalid SB3 (ZIP) file" << std::endl;
return;
}
properties["End of central directory signature"] = *reinterpret_cast<uint32_t*>(&data[eocdPos]);
properties["Number of this disk"] = *reinterpret_cast<uint16_t*>(&data[eocdPos + 4]);
properties["Number of the disk with the start of the central directory"] = *reinterpret_cast<uint16_t*>(&data[eocdPos + 6]);
properties["Total number of central directory entries on this disk"] = *reinterpret_cast<uint16_t*>(&data[eocdPos + 8]);
properties["Total number of central directory entries"] = *reinterpret_cast<uint16_t*>(&data[eocdPos + 10]);
properties["Size of the central directory"] = *reinterpret_cast<uint32_t*>(&data[eocdPos + 12]);
properties["Offset of start of central directory"] = *reinterpret_cast<uint32_t*>(&data[eocdPos + 16]);
uint16_t commentLen = *reinterpret_cast<uint16_t*>(&data[eocdPos + 20]);
properties["ZIP file comment length"] = commentLen;
// Comment as string (add to map as uint64_t cast or separate string map if needed)
// Parse central directory (similar logic)
size_t cdPos = properties["Offset of start of central directory"];
for (size_t i = 0; i < properties["Total number of central directory entries"]; ++i) {
std::map<std::string, uint64_t> entry;
entry["Central directory file header signature"] = *reinterpret_cast<uint32_t*>(&data[cdPos]);
entry["Version made by"] = *reinterpret_cast<uint16_t*>(&data[cdPos + 4]);
// ... (add all)
uint16_t fnLen = *reinterpret_cast<uint16_t*>(&data[cdPos + 28]);
uint16_t extraLen = *reinterpret_cast<uint16_t*>(&data[cdPos + 30]);
uint16_t commLen = *reinterpret_cast<uint16_t*>(&data[cdPos + 32]);
// File name as string (handle separately)
cdPos += 46 + fnLen + extraLen + commLen;
size_t localPos = entry["Relative offset of local file header"];
entry["Local file header signature"] = *reinterpret_cast<uint32_t*>(&data[localPos]);
// ...
entries.push_back(entry);
}
}
void printProperties() {
std::cout << "Global Properties:" << std::endl;
for (const auto& p : properties) {
std::cout << p.first << ": " << p.second << std::endl;
}
for (size_t i = 0; i < entries.size(); ++i) {
std::cout << "\nEntry " << i << " Properties:" << std::endl;
for (const auto& e : entries[i]) {
std::cout << e.first << ": " << e.second << std::endl;
}
}
}
void write(const std::string& newFilepath = "") {
std::string outPath = newFilepath.empty() ? filepath : newFilepath;
std::ofstream out(outPath, std::ios::binary);
out.write(data.data(), data.size());
std::cout << "Wrote to " << outPath << std::endl;
}
};
// Example usage:
// int main() {
// SB3File sb3("example.sb3");
// sb3.printProperties();
// sb3.write("modified.sb3");
// return 0;
// }
(Note: C++ used for class; strings and variable fields handled minimally—expand for full. Write is copy stub.)