decode and preview individual fields in .sb files

This commit is contained in:
Michael "Z" Goddard 2018-12-04 15:34:57 -05:00
parent c999f2bc31
commit 61a611b979
No known key found for this signature in database
GPG key ID: 762CD40DD5349872
27 changed files with 3965 additions and 2826 deletions

3
.babelrc Normal file
View file

@ -0,0 +1,3 @@
{
"presets": ["@babel/preset-env"]
}

View file

@ -6,8 +6,8 @@ env:
global:
- NODE_ENV=production
matrix:
- NPM_SCRIPT="test:unit -- --jobs=4"
- NPM_SCRIPT="test:integration -- --jobs=4"
- NPM_SCRIPT="test:ci:unit -- --jobs=4"
- NPM_SCRIPT="test:ci:integration -- --jobs=4"
cache:
directories:
- "$HOME/.npm"

View file

@ -0,0 +1,3 @@
const {SB1File} = require('./src');
exports.SB1File = SB1File;

5396
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -18,25 +18,32 @@
"lint": "eslint .",
"prepublish": "in-publish && npm run build || not-in-publish",
"start": "webpack-dev-server",
"test:unit": "tap ./test/unit/*.js",
"test:integration": "tap ./test/integration/*.js",
"test:ci:build": "babel --presets @babel/preset-env . --out-dir dist/test-tmp --only src,test,index.js --source-maps",
"test:ci:unit": "npm run test:ci:build && tap ./dist/test-tmp/test/unit",
"test:ci:integration": "npm run test:ci:build && tap ./dist/test-tmp/test/integration",
"test:ci": "npm run test:ci:build && tap ./dist/test-tmp/test",
"test:unit": "tap --node-arg=--require --node-arg=@babel/register ./test/unit",
"test:integration": "tap --node-arg=--require --node-arg=@babel/register ./test/integration",
"test": "npm run lint && npm run docs && npm run test:unit && npm run test:integration",
"watch": "webpack --progress --colors --watch",
"version": "json -f package.json -I -e \"this.repository.sha = '$(git log -n1 --pretty=format:%H)'\""
},
"dependencies": {
"minilog": "3.1.0",
"text-encoding": "0.6.4"
"text-encoding": "^0.6.4"
},
"devDependencies": {
"@babel/cli": "^7.2.0",
"@babel/core": "^7.1.2",
"@babel/preset-env": "^7.1.0",
"@babel/register": "^7.0.0",
"babel-eslint": "^10.0.1",
"babel-loader": "^8.0.4",
"docdash": "^1.0.0",
"eslint": "^5.3.0",
"eslint-config-scratch": "^5.0.0",
"gh-pages": "^1.2.0",
"html-webpack-plugin": "^3.2.0",
"in-publish": "^2.0.0",
"jsdoc": "^3.5.5",
"json": "^9.0.4",

View file

@ -0,0 +1,57 @@
class Packet {
constructor (uint8 = new Uint8Array(this.size), offset = 0) {
this.uint8 = uint8;
this.offset = offset;
}
equals (other) {
for (const key in other) {
if (this[key] !== other[key]) {
return false;
}
}
return true;
}
view () {
const className = this.constructor.name;
const obj = {
toString () {
return className;
}
};
for (const key in this.shape) {
obj[key] = this[key];
}
return obj;
}
static initConstructor (PacketConstructor) {
PacketConstructor.size = PacketConstructor.prototype.size;
return PacketConstructor;
}
static extend (shape) {
const DefinedPacket = class extends Packet {
get shape () {
return shape;
}
};
let position = 0;
Object.keys(shape).forEach(key => {
Object.defineProperty(DefinedPacket.prototype, key, shape[key].asPropertyObject(position));
if (shape[key].size === 0) {
throw new Error('Packet cannot be defined with variable sized members.');
}
position += shape[key].size;
});
DefinedPacket.prototype.size = position;
DefinedPacket.size = position;
return DefinedPacket;
}
}
export {Packet};

View file

@ -0,0 +1,262 @@
import {assert} from '../util/assert';
const notImplemented = () => {
throw new Error('Not implemented');
};
const IS_HOST_LITTLE_ENDIAN = (() => {
const ab16 = new Uint16Array(1);
const ab8 = new Uint8Array(ab16.buffer);
ab16[0] = 0xaabb;
return ab8[0] === 0xbb;
})();
class BytePrimitive {
constructor ({
size = 0,
sizeOf = () => size,
writeSizeOf = notImplemented,
toBytes = new Uint8Array(1),
read,
write = notImplemented
}) {
this.size = size;
this.sizeOf = sizeOf;
this.writeSizeOf = writeSizeOf;
this.toBytes = toBytes;
this.bytes = new Uint8Array(toBytes.buffer);
this.read = read;
this.write = write;
}
asPropertyObject (position) {
const _this = this;
return {
get () {
return _this.read(this.uint8, position + this.offset);
},
set (value) {
return _this.write(this.uint8, position + this.offset, value);
},
enumerable: true
};
}
}
const Uint8 = new BytePrimitive({
size: 1,
read (uint8, position) {
return uint8[position];
},
write (uint8, position, value) {
uint8[position] = value;
return value;
}
});
const HOSTLE_BE16 = {
size: 2,
// toBytes: Defined by instance.
read (uint8, position) {
this.bytes[1] = uint8[position + 0];
this.bytes[0] = uint8[position + 1];
return this.toBytes[0];
},
write (uint8, position, value) {
this.toBytes[0] = value;
uint8[position + 0] = this.bytes[1];
uint8[position + 1] = this.bytes[0];
return value;
}
};
const HOSTBE_BE16 = {
size: 2,
// toBytes: Defined by instance.
read (uint8, position) {
this.bytes[0] = uint8[position + 0];
this.bytes[1] = uint8[position + 1];
return this.toBytes[0];
},
write (uint8, position, value) {
this.toBytes[0] = value;
uint8[position + 0] = this.bytes[0];
uint8[position + 1] = this.bytes[1];
return value;
}
};
let BE16;
if (IS_HOST_LITTLE_ENDIAN) {
BE16 = HOSTLE_BE16;
} else {
BE16 = HOSTBE_BE16;
}
const Uint16BE = new BytePrimitive(Object.assign({}, BE16, {
toBytes: new Uint16Array(1)
}));
const Int16BE = new BytePrimitive(Object.assign({}, BE16, {
toBytes: new Int16Array(1)
}));
const HOSTLE_BE32 = {
size: 4,
// toBytes: Defined by instance.
read (uint8, position) {
this.bytes[3] = uint8[position + 0];
this.bytes[2] = uint8[position + 1];
this.bytes[1] = uint8[position + 2];
this.bytes[0] = uint8[position + 3];
return this.toBytes[0];
},
write (uint8, position, value) {
this.toBytes[0] = value;
uint8[position + 0] = this.bytes[3];
uint8[position + 1] = this.bytes[2];
uint8[position + 2] = this.bytes[1];
uint8[position + 3] = this.bytes[0];
return value;
}
};
const HOSTBE_BE32 = {
size: 4,
// toBytes: Defined by instance.
read (uint8, position) {
this.bytes[0] = uint8[position + 0];
this.bytes[1] = uint8[position + 1];
this.bytes[2] = uint8[position + 2];
this.bytes[3] = uint8[position + 3];
return this.toBytes[0];
},
write (uint8, position, value) {
this.toBytes[0] = value;
uint8[position + 0] = this.bytes[0];
uint8[position + 1] = this.bytes[1];
uint8[position + 2] = this.bytes[2];
uint8[position + 3] = this.bytes[3];
return value;
}
};
let BE32;
if (IS_HOST_LITTLE_ENDIAN) {
BE32 = HOSTLE_BE32;
} else {
BE32 = HOSTBE_BE32;
}
const Int32BE = new BytePrimitive(Object.assign({}, BE32, {
toBytes: new Int32Array(1)
}));
const Uint32BE = new BytePrimitive(Object.assign({}, BE32, {
toBytes: new Uint32Array(1)
}));
let LE16;
if (IS_HOST_LITTLE_ENDIAN) {
LE16 = HOSTBE_BE16;
} else {
LE16 = HOSTLE_BE16;
}
const Uint16LE = new BytePrimitive(Object.assign({}, LE16, {
toBytes: new Uint16Array(1)
}));
let LE32;
if (IS_HOST_LITTLE_ENDIAN) {
LE32 = HOSTBE_BE32;
} else {
LE32 = HOSTLE_BE32;
}
const Uint32LE = new BytePrimitive(Object.assign({}, LE32, {
toBytes: new Uint32Array(1)
}));
const HOSTLE_BEDOUBLE = {
size: 8,
read (uint8, position) {
this.bytes[7] = uint8[position + 0];
this.bytes[6] = uint8[position + 1];
this.bytes[5] = uint8[position + 2];
this.bytes[4] = uint8[position + 3];
this.bytes[3] = uint8[position + 4];
this.bytes[2] = uint8[position + 5];
this.bytes[1] = uint8[position + 6];
this.bytes[0] = uint8[position + 7];
return this.toBytes[0];
}
};
const HOSTBE_BEDOUBLE = {
size: 8,
read (uint8, position) {
this.bytes[7] = uint8[position + 0];
this.bytes[6] = uint8[position + 1];
this.bytes[5] = uint8[position + 2];
this.bytes[4] = uint8[position + 3];
this.bytes[3] = uint8[position + 4];
this.bytes[2] = uint8[position + 5];
this.bytes[1] = uint8[position + 6];
this.bytes[0] = uint8[position + 7];
return this.toBytes[0];
}
};
let BEDOUBLE;
if (IS_HOST_LITTLE_ENDIAN) {
BEDOUBLE = HOSTLE_BEDOUBLE;
} else {
BEDOUBLE = HOSTBE_BEDOUBLE;
}
const DoubleBE = new BytePrimitive(Object.assign({}, BEDOUBLE, {
toBytes: new Float64Array(1)
}));
class FixedAsciiString extends BytePrimitive {
constructor (size) {
super({
size,
read (uint8, position) {
let str = '';
for (let i = 0; i < size; i++) {
const code = uint8[position + i];
assert(code <= 127, 'Non-ascii character in FixedAsciiString');
str += String.fromCharCode(code);
}
return str;
},
write (uint8, position, value) {
for (let i = 0; i < size; i++) {
const code = value.charCodeAt(i);
assert(code <= 127, 'Non-ascii character in FixedAsciiString');
uint8[position + i] = code;
}
return value;
}
});
}
}
export {
IS_HOST_LITTLE_ENDIAN,
BytePrimitive,
Uint8,
Uint16BE,
Int16BE,
Int32BE,
Uint32BE,
Uint16LE,
Uint32LE,
DoubleBE,
FixedAsciiString
};

75
src/coders/byte-stream.js Normal file
View file

@ -0,0 +1,75 @@
import {assert} from '../util/assert';
class ByteStream {
constructor (buffer, position = 0) {
this.buffer = buffer;
this.position = position;
this.uint8 = new Uint8Array(this.buffer);
}
read (member) {
const value = member.read(this.uint8, this.position);
if (member.size === 0) {
this.position += member.sizeOf(this.uint8, this.position);
} else {
this.position += member.size;
}
return value;
}
readStruct (StructType) {
const obj = new StructType(this.uint8, this.position);
this.position += StructType.size;
return obj;
}
resize (needed) {
if (this.buffer.byteLength < needed) {
const uint8 = this.uint8;
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log(needed) / Math.log(2)));
this.buffer = new ArrayBuffer(nextPowerOf2);
this.uint8 = new Uint8Array(this.buffer);
this.uint8.set(uint8);
}
}
write (member, value) {
if (member.size === 0) {
this.resize(this.position + member.writeSizeOf(value));
} else {
this.resize(this.position + member.size);
}
member.write(this.uint8, this.position, value);
if (member.size === 0) {
this.position += member.writeSizeOf(this.uint8, this.position);
} else {
this.position += member.size;
}
return value;
}
writeStruct (StructType, data) {
this.resize(this.position + StructType.size);
const st = Object.assign(new StructType(this.uint8, this.position), data);
this.position += StructType.size;
return st;
}
writeBytes (bytes, start = 0, end = bytes.length) {
assert(bytes instanceof Uint8Array, 'writeBytes must be passed an Uint8Array');
this.resize(this.position + (end - start));
for (let i = start; i < end; i++) {
this.uint8[this.position + i - start] = bytes[i];
}
this.position += end - start;
return bytes;
}
}
export {ByteStream};

1
src/index.js Normal file
View file

@ -0,0 +1 @@
export {SB1File} from './sb1-file';

84
src/playground/array.js Normal file
View file

@ -0,0 +1,84 @@
import {assert} from '../util/assert';
class SB1ArrayAbstractView {
constructor (array, start, end) {
this.array = array instanceof SB1ArrayAbstractView ? array.array : array;
this.start = start;
this.end = end;
}
get length () {
return this.end - this.start;
}
get name () {
throw new Error('Not implemented');
}
map (fn) {
const out = [];
for (let i = this.start; i < this.end; i++) {
out.push(fn(this.array[i], i, this));
}
return out;
}
}
class SB1ArrayFullView extends SB1ArrayAbstractView {
constructor (array) {
super(array, array.start || 0, array.end || array.length);
}
get name () {
return 'all';
}
}
class SB1ArraySubView extends SB1ArrayAbstractView {
get name () {
return `${this.start + 1} - ${this.end}`;
}
static views (array) {
if (array instanceof SB1ArrayFullView) {
return array;
}
if (array.length > 100) {
const scale = Math.pow(10, Math.ceil(Math.log(array.length) / Math.log(10)));
const increment = scale / 10;
const views = [];
for (let i = (array.start || 0); i < (array.end || array.length); i += increment) {
views.push(new SB1ArraySubView(array, i, Math.min(i + increment, array.end || array.length)));
assert(views.length <= 10, 'Too many subviews');
}
views.push(new SB1ArrayFullView(array));
return views;
}
return array;
}
}
class ArrayRenderer {
static check (data) {
return Array.isArray(data) || data instanceof SB1ArrayAbstractView;
}
render (data, view) {
if (data.length) view.renderArrow();
view.renderTitle(`Array (${data.length})`);
if (data.length) {
view.renderExpand(() => (
SB1ArraySubView.views(data)
.map((field, index) => (
view.child(
field,
field instanceof SB1ArrayAbstractView ? field.name : index + 1, `[${index}]`
)
))
));
}
}
}
export {ArrayRenderer};

29
src/playground/field.js Normal file
View file

@ -0,0 +1,29 @@
import {Field, Header, Reference, Value} from '../squeak/fields';
import {TYPES, TYPE_NAMES} from '../squeak/ids';
class FieldRenderer {
static check (data) {
return data instanceof Field;
}
render (data, view) {
if (data instanceof Reference) {
view.renderTitle(`Reference { index: ${data.index} }`);
} else if (data instanceof Header) {
view.renderTitle(`Header { classId: ${data.classId} (${TYPE_NAMES[data.classId]}), size: ${data.size} }`);
} else if ((data instanceof Value) && (
data.classId === TYPES.COLOR ||
data.classId === TYPES.TRANSLUCENT_COLOR
)) {
view.renderTitle((+data).toString(16).padStart(8, '0')).style.fontFamily = 'monospace';
} else if (data instanceof Value) {
if (data.value && data.value.buffer) {
view.renderTitle(`${data.value.constructor.name} (${data.value.length})`);
} else {
view.renderTitle(String(data));
}
}
}
}
export {FieldRenderer};

View file

@ -0,0 +1 @@
<input type="file" class="file" />

49
src/playground/index.js Normal file
View file

@ -0,0 +1,49 @@
import {SB1File} from '../..';
import {SB1View} from './view';
import {ArrayRenderer} from './array';
import {FieldRenderer} from './field';
import {JSPrimitiveRenderer} from './js-primitive';
import {ObjectRenderer} from './object';
import {ViewableRenderer} from './viewable';
SB1View.register(ArrayRenderer);
SB1View.register(FieldRenderer);
SB1View.register(JSPrimitiveRenderer);
SB1View.register(ObjectRenderer);
SB1View.register(ViewableRenderer);
let last = null;
const readFile = f => {
const reader = new FileReader();
reader.onload = function (event) {
if (last) {
last.forEach(document.body.removeChild, document.body);
}
const sb1 = new SB1File(event.target.result);
last = [
new SB1View(sb1, 'file').element,
new SB1View(Array.from(sb1.infoRaw()), 'raw - info').element,
new SB1View(Array.from(sb1.dataRaw()), 'raw - data').element
];
last.forEach(document.body.appendChild, document.body);
};
reader.readAsArrayBuffer(f);
};
Array.from(document.getElementsByClassName('file')).forEach(el => {
el.addEventListener('change', () => {
Array.from(el.files).forEach(readFile);
});
});
document.body.addEventListener('drop', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
document.getElementsByClassName('file')[0].files = e.dataTransfer.files;
});
document.body.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});

View file

@ -0,0 +1,13 @@
const PRIMITIVE_TYPEOFS = ['undefined', 'string', 'number', 'boolean'];
class JSPrimitiveRenderer {
static check (data) {
return PRIMITIVE_TYPEOFS.includes(typeof data) || data === null;
}
render (data, view) {
view.renderTitle(String(data));
}
}
export {JSPrimitiveRenderer};

28
src/playground/object.js Normal file
View file

@ -0,0 +1,28 @@
const log = require('../util/log');
class ObjectRenderer {
static check (data) {
return data && data.constructor === Object;
}
render (dataOrFn, view) {
view.renderArrow();
view.renderTitle(String(dataOrFn) === '[object Object]' ? 'Object' : String(dataOrFn));
view.renderExpand(() => {
const data = typeof dataOrFn === 'function' ? dataOrFn() : dataOrFn;
return Object.keys(data)
.map(key => {
try {
if (typeof data[key] === 'function') return null;
return view.child(data[key], key, `.${key}`);
} catch (err) {
log.error(err);
return view.child('An error occured rendering view data.', key, `.${key}`);
}
})
.filter(Boolean);
});
}
}
export {ObjectRenderer};

171
src/playground/view.js Normal file
View file

@ -0,0 +1,171 @@
const log = require('../util/log');
const _expanded = {};
const _registry = [];
class DefaultRenderer {
static check () {
return true;
}
render (data, view) {
if (data instanceof HTMLElement) {
view.content.appendChild(data);
} else {
view.renderTitle(`Unknown Structure(${data ? data.classId || data.constructor.name : ''})`);
}
}
}
class SB1View {
constructor (data, prefix = '', path = prefix) {
this._elements = {};
this.element = document.createElement('div');
this.element.style.position = 'relative';
this.element.style.top = '0';
this.element.style.left = '0';
// this.element.style.overflow = 'hidden';
this.content = this.element;
this.data = data;
this.prefix = prefix;
this.path = path;
this.expanded = !!_expanded[this.path];
this.canExpand = false;
this.toggle = this.toggle.bind(this);
this.element.addEventListener('click', this.toggle);
this.renderer = SB1View.createRenderer(this.data, this);
this.render();
}
toggle (event) {
if (!this.canExpand) return;
if (event.target !== this._elements.arrow && event.target !== this._elements.title) return;
_expanded[this.path] = this.expanded = !this.expanded;
this.render();
event.preventDefault();
event.stopPropagation();
return false;
}
createElement (type, name) {
if (!this._elements[name]) {
this._elements[name] = document.createElement(type);
}
this._elements[name].innerHTML = '';
return this._elements[name];
}
child (value, key, path) {
return new SB1View(value, key, `${this.path}${path}`);
}
renderClear () {
this.canExpand = false;
while (this.element.children.length) {
this.element.removeChild(this.element.children[0]);
}
this.content = this.element;
}
renderArrow () {
this.canExpand = true;
const arrowDiv = this.createElement('div', 'arrow');
arrowDiv.innerHTML = '&#x25b6;';
arrowDiv.style.position = 'absolute';
arrowDiv.style.left = '0';
arrowDiv.style.width = '1em';
arrowDiv.style.transform = this.expanded ? 'rotateZ(90deg)' : '';
arrowDiv.style.transition = 'transform 3s';
this.element.appendChild(arrowDiv);
const contentDiv = this.createElement('div', 'arrowContent');
contentDiv.style.position = 'relative';
contentDiv.style.left = '1em';
contentDiv.style.right = '0';
this.element.appendChild(contentDiv);
this.content = contentDiv;
}
renderTitle (title) {
const titleDiv = this.createElement('div', 'title');
const fullTitle = (this.prefix ? `${this.prefix}: ` : '') + title;
if (['\n', '\r', '<br>'].some(str => fullTitle.indexOf(str) !== -1) || fullTitle.length > 80) {
this.renderArrow();
if (this.expanded) {
titleDiv.innerText = fullTitle;
} else {
const maxLength = Math.min(
fullTitle.lastIndexOf(' ', 80),
['\n', '\r', '<br>'].reduce((value, str) => {
if (fullTitle.indexOf(str) !== -1) {
return Math.min(value, fullTitle.indexOf(str));
}
return value;
}, Infinity)
);
titleDiv.innerText = `${fullTitle.substring(0, maxLength)} ...`;
}
} else {
titleDiv.innerText = fullTitle;
}
this.content.appendChild(titleDiv);
return titleDiv;
}
expand (fn, elsefn) {
if (this.expanded) {
elsefn();
} else {
fn();
}
}
renderExpand (fn) {
if (this.expanded) {
try {
const div = this.createElement('div', 'expanded');
fn.call(this, div)
.forEach(view => this.content.appendChild(view.element));
} catch (error) {
log.error(error);
const divError = this.createElement('div', 'expanded-error');
divError.innerText = 'Error rendering expanded area ...';
this.content.appendChild(divError);
}
}
}
render () {
this.renderClear();
this.renderer.render(this.data, this);
}
static register (Class) {
_registry.push([Class.check, Class]);
}
static findRenderer (data, view) {
return _registry.reduce((carry, [check, Class]) => {
if (check(data, view)) return Class;
return carry;
}, DefaultRenderer);
}
static createRenderer (data, view) {
const Renderer = SB1View.findRenderer(data, view);
return new Renderer(view);
}
}
export {SB1View};

View file

@ -0,0 +1,17 @@
import {ObjectRenderer} from './object';
class ViewableRenderer {
static check (data) {
return data && typeof data.view === 'function';
}
render (data, view) {
new ObjectRenderer().render(Object.assign(() => data.view(), {
toString () {
return data.constructor.name;
}
}), view);
}
}
export {ViewableRenderer};

43
src/sb1-file-packets.js Normal file
View file

@ -0,0 +1,43 @@
import {assert} from './util/assert';
import {Packet} from './coders/byte-packets';
import {FixedAsciiString, Uint8, Uint32BE} from './coders/byte-primitives';
class SB1Signature extends Packet.extend({
version: new FixedAsciiString(10),
infoByteLength: Uint32BE
}) {
validate () {
assert(
this.equals({version: 'ScratchV01'}) ||
this.equals({version: 'ScratchV02'}),
'Invalid Scratch file signature.'
);
}
}
Packet.initConstructor(SB1Signature);
class SB1Header extends Packet.extend({
ObjS: new FixedAsciiString(4),
ObjSValue: Uint8,
Stch: new FixedAsciiString(4),
StchValue: Uint8,
numObjects: Uint32BE
}) {
validate () {
assert(
this.equals({
ObjS: 'ObjS',
ObjSValue: 1,
Stch: 'Stch',
StchValue: 1
}),
'Invalid Scratch file info packet header.'
);
}
}
Packet.initConstructor(SB1Header);
export {SB1Signature, SB1Header};

51
src/sb1-file.js Normal file
View file

@ -0,0 +1,51 @@
import {ByteStream} from './coders/byte-stream';
import {ByteTakeIterator} from './squeak/byte-take-iterator';
import {FieldIterator} from './squeak/field-iterator';
import {SB1Header, SB1Signature} from './sb1-file-packets';
class SB1File {
constructor (buffer) {
this.buffer = buffer;
this.stream = new ByteStream(buffer);
this.signature = this.stream.readStruct(SB1Signature);
this.signature.validate();
this.infoHeader = this.stream.readStruct(SB1Header);
this.infoHeader.validate();
this.stream.position += this.signature.infoByteLength - SB1Header.size;
this.dataHeader = this.stream.readStruct(SB1Header);
this.dataHeader.validate();
}
view () {
return {
signature: this.signature,
infoHeader: this.infoHeader,
dataHeader: this.dataHeader,
toString () {
return 'SB1File';
}
};
}
infoRaw () {
return new ByteTakeIterator(
new FieldIterator(this.buffer, this.infoHeader.offset + SB1Header.size),
this.signature.infoByteLength + SB1Signature.size
);
}
dataRaw () {
return new ByteTakeIterator(
new FieldIterator(this.buffer, this.dataHeader.offset + SB1Header.size),
this.stream.uint8.length
);
}
}
export {SB1File};

View file

@ -0,0 +1,173 @@
import {TextDecoder as JSTextDecoder} from 'text-encoding';
import {assert} from '../util/assert';
import {IS_HOST_LITTLE_ENDIAN, Int16BE, BytePrimitive, Uint8, Uint32BE} from '../coders/byte-primitives';
const BUFFER_TOO_BIG = 10 * 1024 * 1024;
let ReferenceBE;
if (IS_HOST_LITTLE_ENDIAN) {
ReferenceBE = new BytePrimitive({
size: 3,
read (uint8, position) {
return (
(uint8[position + 0] << 16) |
(uint8[position + 1] << 8) |
uint8[position + 2]
);
}
});
} else {
ReferenceBE = new BytePrimitive({
size: 3,
read (uint8, position) {
return (
(uint8[position + 2] << 16) |
(uint8[position + 1] << 8) |
uint8[position + 0]
);
}
});
}
const LargeInt = new BytePrimitive({
sizeOf (uint8, position) {
const count = Int16BE.read(uint8, position);
return Int16BE.size + count;
},
read (uint8, position) {
let num = 0;
let multiplier = 0;
const count = Int16BE.read(uint8, position);
for (let i = 0; i < count; i++) {
num = num + (multiplier * Uint8.read(uint8, position++));
multiplier *= 256;
}
return num;
}
});
const AsciiString = new BytePrimitive({
sizeOf (uint8, position) {
const count = Uint32BE.read(uint8, position);
return Uint32BE.size + count;
},
read (uint8, position) {
const count = Uint32BE.read(uint8, position);
assert(count < BUFFER_TOO_BIG, 'asciiString too big');
position += 4;
let str = '';
for (let i = 0; i < count; i++) {
str += String.fromCharCode(uint8[position++]);
}
return str;
}
});
const Bytes = new BytePrimitive({
sizeOf (uint8, position) {
return Uint32BE.size + Uint32BE.read(uint8, position);
},
read (uint8, position) {
const count = Uint32BE.read(uint8, position);
assert(count < BUFFER_TOO_BIG, 'bytes too big');
position += Uint32BE.size;
assert(count < BUFFER_TOO_BIG, 'uint8 array too big');
return new Uint8Array(uint8.buffer, position, count);
}
});
const SoundBytes = new BytePrimitive({
sizeOf (uint8, position) {
return Uint32BE.size + (Uint32BE.read(uint8, position) * 2);
},
read (uint8, position) {
const samples = Uint32BE.read(uint8, position);
assert(samples < BUFFER_TOO_BIG, 'sound too big');
position += Uint32BE.size;
const count = samples * 2;
assert(count < BUFFER_TOO_BIG, 'uint8 array too big');
return new Uint8Array(uint8.buffer, position, count);
}
});
const Bitmap32BE = new BytePrimitive({
sizeOf (uint8, position) {
return Uint32BE.size + (Uint32BE.read(uint8, position) * Uint32BE.size);
},
read (uint8, position) {
const count = Uint32BE.read(uint8, position);
assert(count < BUFFER_TOO_BIG, 'bitmap too big');
position += Uint32BE.size;
assert(count < BUFFER_TOO_BIG, 'uint8 array too big');
const value = new Uint32Array(count);
for (let i = 0; i < count; i++) {
value[i] = Uint32BE.read(uint8, position);
position += Uint32BE.size;
}
return value;
}
});
let decoder;
/* global TextDecoder:true */
if (typeof TextDecoder === 'undefined') {
decoder = new JSTextDecoder();
} else {
decoder = new TextDecoder();
}
const UTF8 = new BytePrimitive({
sizeOf (uint8, position) {
return Uint32BE.size + Uint32BE.read(uint8, position);
},
read (uint8, position) {
const count = Uint32BE.read(uint8, position);
assert(count < BUFFER_TOO_BIG, 'utf8 too big');
position += Uint32BE.size;
assert(count < BUFFER_TOO_BIG, 'uint8 array too big');
return decoder.decode(new Uint8Array(uint8.buffer, position, count));
}
});
const OpaqueColor = new BytePrimitive({
size: 4,
read (uint8, position) {
const rgb = Uint32BE.read(uint8, position);
const a = 0xff;
const r = (rgb >> 22) & 0xff;
const g = (rgb >> 12) & 0xff;
const b = (rgb >> 2) & 0xff;
return ((a << 24) | (r << 16) | (g << 8) | b) >>> 0;
}
});
const TranslucentColor = new BytePrimitive({
size: 5,
read (uint8, position) {
const rgb = Uint32BE.read(uint8, position);
const a = Uint8.read(uint8, position);
const r = (rgb >> 22) & 0xff;
const g = (rgb >> 12) & 0xff;
const b = (rgb >> 2) & 0xff;
return ((a << 24) | (r << 16) | (g << 8) | b) >>> 0;
}
});
export {
BUFFER_TOO_BIG,
ReferenceBE,
LargeInt,
AsciiString,
Bytes,
SoundBytes,
Bitmap32BE,
UTF8,
OpaqueColor,
TranslucentColor
};

View file

@ -0,0 +1,23 @@
class ByteTakeIterator {
constructor (iter, maxPosition = Infinity) {
this.iter = iter;
this.maxPosition = maxPosition;
}
[Symbol.iterator] () {
return this;
}
next () {
if (this.iter.stream.position >= this.maxPosition) {
return {
value: null,
done: true
};
}
return this.iter.next();
}
}
export {ByteTakeIterator};

View file

@ -0,0 +1,114 @@
import {Uint8, Int16BE, Int32BE, DoubleBE} from '../coders/byte-primitives';
import {ByteStream} from '../coders/byte-stream';
import {
ReferenceBE, LargeInt, AsciiString, UTF8, Bytes, SoundBytes, Bitmap32BE, OpaqueColor, TranslucentColor
} from './byte-primitives';
import {BuiltinObjectHeader, FieldObjectHeader, Header, Reference, Value} from './fields';
import {TYPES} from './ids';
class Consumer {
constructor ({
type = Value,
read,
value = read ? (stream => stream.read(read)) : null
}) {
this.type = type;
this.value = value;
}
next (stream, classId, position) {
return {
value: new this.type(classId, position, this.value(stream)),
done: false
};
}
}
const CONSUMER_PROTOS = {
[TYPES.NULL]: {value: () => null},
[TYPES.TRUE]: {value: () => true},
[TYPES.FALSE]: {value: () => false},
[TYPES.SMALL_INT]: {read: Int32BE},
[TYPES.SMALL_INT_16]: {read: Int16BE},
[TYPES.LARGE_INT_POSITIVE]: {read: LargeInt},
[TYPES.LARGE_INT_NEGATIVE]: {read: LargeInt},
[TYPES.FLOATING]: {read: DoubleBE},
[TYPES.STRING]: {read: AsciiString},
[TYPES.SYMBOL]: {read: AsciiString},
[TYPES.BYTES]: {read: Bytes},
[TYPES.SOUND]: {read: SoundBytes},
[TYPES.BITMAP]: {read: Bitmap32BE},
[TYPES.UTF8]: {read: UTF8},
[TYPES.ARRAY]: {type: Header, read: Int32BE},
[TYPES.ORDERED_COLLECTION]: {type: Header, read: Int32BE},
[TYPES.SET]: {type: Header, read: Int32BE},
[TYPES.IDENTITY_SET]: {type: Header, read: Int32BE},
[TYPES.DICTIONARY]: {
type: Header,
value: stream => stream.read(Int32BE) * 2
},
[TYPES.IDENTITY_DICTIONARY]: {
type: Header,
value: stream => stream.read(Int32BE) * 2
},
[TYPES.COLOR]: {read: OpaqueColor},
[TYPES.TRANSLUCENT_COLOR]: {read: TranslucentColor},
[TYPES.POINT]: {type: Header, value: () => 2},
[TYPES.RECTANGLE]: {type: Header, value: () => 4},
[TYPES.FORM]: {type: Header, value: () => 5},
[TYPES.SQUEAK]: {type: Header, value: () => 6},
[TYPES.OBJECT_REF]: {type: Reference, read: ReferenceBE}
};
const CONSUMERS = new Array(256).fill(null);
for (const index of Object.values(TYPES)) {
if (CONSUMER_PROTOS[index]) {
CONSUMERS[index] = new Consumer(CONSUMER_PROTOS[index]);
}
}
const builtinConsumer = new Consumer({
type: BuiltinObjectHeader,
value: () => null
});
class FieldIterator {
constructor (buffer, position) {
this.buffer = buffer;
this.stream = new ByteStream(buffer, position);
}
[Symbol.iterator] () {
return this;
}
next () {
if (this.stream.position >= this.stream.uint8.length) {
return {
value: null,
done: true
};
}
const position = this.stream.position;
const classId = this.stream.read(Uint8);
const consumer = CONSUMERS[classId];
if (consumer !== null) {
return consumer.next(this.stream, classId, position);
} else if (classId < TYPES.OBJECT_REF) {
// TODO: Does this ever happen?
return builtinConsumer.next(this.stream, classId, position);
}
const classVersion = this.stream.read(Uint8);
const size = this.stream.read(Uint8);
return {
value: new FieldObjectHeader(classId, position, classVersion, size),
done: false
};
}
}
export {FieldIterator};

80
src/squeak/fields.js Normal file
View file

@ -0,0 +1,80 @@
import {TYPES} from './ids';
class Field {
constructor (classId, position) {
this.classId = classId;
this.position = position;
}
}
const valueOf = obj => {
if (typeof obj === 'object' && obj) return obj.valueOf();
return obj;
};
class Value extends Field {
constructor (classId, position, value) {
super(classId, position);
this.value = value;
}
valueOf () {
return this.value;
}
toJSON () {
if (
this.classId === TYPES.TRANSLUCENT_COLOR ||
this.classId === TYPES.COLOR
) {
// TODO: Can colors be 32 bit in scratch-packets?
return this.value & 0xffffff;
}
return this.value;
}
toString () {
return this.value;
}
}
class Header extends Field {
constructor (classId, position, size) {
super(classId, position);
this.size = size;
}
}
class Reference extends Field {
constructor (classId, position, index) {
super(classId, position);
this.index = index;
}
valueOf () {
return `Ref(${this.index})`;
}
}
class BuiltinObjectHeader extends Header {
constructor (classId, position) {
super(classId, position, 0);
}
}
class FieldObjectHeader extends Header {
constructor (classId, position, version, size) {
super(classId, position, size);
this.version = version;
}
}
export {
Field,
valueOf as value,
Value,
Header,
Reference,
BuiltinObjectHeader,
FieldObjectHeader
};

54
src/squeak/ids.js Normal file
View file

@ -0,0 +1,54 @@
const TYPES = {
NULL: 1,
TRUE: 2,
FALSE: 3,
SMALL_INT: 4,
SMALL_INT_16: 5,
LARGE_INT_POSITIVE: 6,
LARGE_INT_NEGATIVE: 7,
FLOATING: 8,
STRING: 9,
SYMBOL: 10,
BYTES: 11,
SOUND: 12,
BITMAP: 13,
UTF8: 14,
ARRAY: 20,
ORDERED_COLLECTION: 21,
SET: 22,
IDENTITY_SET: 23,
DICTIONARY: 24,
IDENTITY_DICTIONARY: 25,
COLOR: 30,
TRANSLUCENT_COLOR: 31,
POINT: 32,
RECTANGLE: 33,
FORM: 34,
SQUEAK: 35,
OBJECT_REF: 99,
MORPH: 100,
ALIGNMENT: 104,
// Called String in Scratch 2. To reduce confusion this is called
// STATIC_STRING to differentiate it from STRING in this codebase.
STATIC_STRING: 105,
UPDATING_STRING: 106,
SAMPLED_SOUND: 109,
IMAGE_MORPH: 110,
SPRITE: 124,
STAGE: 125,
WATCHER: 155,
IMAGE_MEDIA: 162,
SOUND_MEDIA: 164,
MULTILINE_STRING: 171,
WATCHER_READOUT_FRAME: 173,
WATCHER_SLIDER: 174,
LIST_WATCHER: 175
};
const TYPE_NAMES = Object.entries(TYPES)
.reduce((carry, [key, value]) => {
carry[value] = key;
return carry;
}, {});
export {TYPES, TYPE_NAMES};

10
src/util/assert.js Normal file
View file

@ -0,0 +1,10 @@
class AssertionError extends Error {}
const assert = function (test, message) {
if (!test) throw new AssertionError(message);
};
export {
AssertionError,
assert
};

3
src/util/log.js Normal file
View file

@ -0,0 +1,3 @@
const minilog = require('minilog');
module.exports = new minilog('sb1-converter');

View file

@ -1,3 +1,35 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: './index.js'
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
devServer: {
contentBase: false,
host: '0.0.0.0',
port: process.env.PORT || 8093
},
entry: {
main: './index.js',
view: './src/playground/index.js'
},
output: {
path: path.resolve(__dirname, 'playground')
},
module: {
rules: [{
test: /\.js$/,
loader: 'babel-loader',
include: path.resolve(__dirname, 'src'),
query: {
presets: [['@babel/preset-env', {targets: {browsers: ['last 3 versions', 'Safari >= 8', 'iOS >= 8']}}]]
}
}]
},
plugins: [
new HtmlWebpackPlugin({
template: './src/playground/index.html',
chunks: ['view']
})
]
};