lexys-labyrinth/js/format-c2m.js
2020-09-16 00:11:52 -06:00

1060 lines
29 KiB
JavaScript

import { DIRECTIONS } from './defs.js';
import * as util from './format-util.js';
import TILE_TYPES from './tiletypes.js';
const CC2_DEMO_INPUT_MASK = {
drop: 0x01,
down: 0x02,
left: 0x04,
right: 0x08,
up: 0x10,
swap: 0x20,
cycle: 0x40,
};
class CC2Demo {
constructor(buf) {
this.buf = buf;
this.bytes = new Uint8Array(buf);
// byte 0 is unknown, always 0?
// Force floor seed can apparently be anything; my best guess, based on the Desert Oasis
// replay, is that it's just incremented and allowed to overflow, so taking it mod 4 gives
// the correct starting direction
this.initial_force_floor_direction = ['north', 'east', 'south', 'west'][this.bytes[1] % 4];
this.blob_seed = this.bytes[2];
}
*[Symbol.iterator]() {
let l = this.bytes.length;
if (l % 2 === 0) {
l--;
}
let input = new Set;
let t = 0;
for (let p = 3; p < l; p += 2) {
// The first byte measures how long the /previous/ input remains
// valid, so yield that first. Note that this is measured in 60Hz
// frames, so we need to convert to 20Hz tics by subtracting 3
// frames at a time.
let delay = this.bytes[p];
if (delay === 0xff)
break;
t += delay;
while (t >= 3) {
t -= 3;
yield input;
}
let input_mask = this.bytes[p + 1];
let is_player_2 = ((input_mask & 0x80) !== 0);
// TODO handle player 2
if (is_player_2)
continue;
for (let [action, bit] of Object.entries(CC2_DEMO_INPUT_MASK)) {
if ((input_mask & bit) === 0) {
input.delete(action);
}
else {
input.add(action);
}
}
}
}
}
let modifier_wire = {
decode(tile, modifier) {
tile.wire_directions = modifier & 0x0f;
tile.wire_tunnel_directions = (modifier & 0xf0) >> 4;
},
encode(tile) {
return tile.wire_directions | (tile.wire_tunnel_directions << 4);
},
};
let arg_direction = {
size: 1,
decode(tile, dirbyte) {
let direction = ['north', 'east', 'south', 'west'][dirbyte & 0x03];
tile.direction = direction;
},
encode(tile) {
return {north: 0, east: 1, south: 2, west: 3}[tile.direction];
},
};
// TODO assert that direction + next match the tile types
const TILE_ENCODING = {
0x01: {
name: 'floor',
modifier: modifier_wire,
},
0x02: {
name: 'wall',
},
0x03: {
name: 'ice',
},
0x04: {
name: 'ice_sw',
},
0x05: {
name: 'ice_nw',
},
0x06: {
name: 'ice_ne',
},
0x07: {
name: 'ice_se',
},
0x08: {
name: 'water',
},
0x09: {
name: 'fire',
},
0x0a: {
name: 'force_floor_n',
},
0x0b: {
name: 'force_floor_e',
},
0x0c: {
name: 'force_floor_s',
},
0x0d: {
name: 'force_floor_w',
},
0x0e: {
name: 'green_wall',
},
0x0f: {
name: 'green_floor',
},
0x10: {
name: 'teleport_red',
modifier: modifier_wire,
},
0x11: {
name: 'teleport_blue',
modifier: modifier_wire,
},
0x12: {
name: 'teleport_yellow',
},
0x13: {
name: 'teleport_green',
},
0x14: {
name: 'exit',
},
0x15: {
name: 'slime',
},
0x16: {
name: 'player',
has_next: true,
extra_args: [arg_direction],
},
0x17: {
name: 'dirt_block',
has_next: true,
extra_args: [arg_direction],
},
0x18: {
name: 'walker',
has_next: true,
extra_args: [arg_direction],
},
0x19: {
name: 'glider',
has_next: true,
extra_args: [arg_direction],
},
0x1a: {
name: 'ice_block',
has_next: true,
extra_args: [arg_direction],
},
0x1b: {
name: 'thinwall_s',
has_next: true,
},
0x1c: {
name: 'thinwall_e',
has_next: true,
},
0x1d: {
name: 'thinwall_se',
has_next: true,
},
0x1e: {
name: 'gravel',
},
0x1f: {
name: 'button_green',
},
0x20: {
name: 'button_blue',
},
0x21: {
name: 'tank_blue',
has_next: true,
extra_args: [arg_direction],
},
0x22: {
name: 'door_red',
},
0x23: {
name: 'door_blue',
},
0x24: {
name: 'door_yellow',
},
0x25: {
name: 'door_green',
},
0x26: {
name: 'key_red',
has_next: true,
},
0x27: {
name: 'key_blue',
has_next: true,
},
0x28: {
name: 'key_yellow',
has_next: true,
},
0x29: {
name: 'key_green',
has_next: true,
},
0x2a: {
name: 'chip',
has_next: true,
},
0x2b: {
name: 'chip_extra',
has_next: true,
},
0x2c: {
name: 'socket',
},
0x2d: {
name: 'popwall',
},
0x2e: {
name: 'wall_appearing',
},
0x2f: {
name: 'wall_invisible',
},
0x30: {
name: 'fake_wall',
},
0x31: {
name: 'fake_floor',
},
0x32: {
name: 'dirt',
},
0x33: {
name: 'bug',
has_next: true,
extra_args: [arg_direction],
},
0x34: {
name: 'paramecium',
has_next: true,
extra_args: [arg_direction],
},
0x35: {
name: 'ball',
has_next: true,
extra_args: [arg_direction],
},
0x36: {
name: 'blob',
has_next: true,
extra_args: [arg_direction],
},
0x37: {
name: 'teeth',
has_next: true,
extra_args: [arg_direction],
},
0x38: {
name: 'fireball',
has_next: true,
extra_args: [arg_direction],
},
0x39: {
name: 'button_red',
},
0x3a: {
name: 'button_brown',
},
0x3b: {
name: 'cleats',
has_next: true,
},
0x3c: {
name: 'suction_boots',
has_next: true,
},
0x3d: {
name: 'fire_boots',
has_next: true,
},
0x3e: {
name: 'flippers',
has_next: true,
},
0x3f: {
name: 'thief_tools',
},
0x40: {
name: 'bomb',
has_next: true,
},
//0x41: Open trap (unused in main levels) :
0x42: {
name: 'trap',
},
0x43: {
name: 'cloner',
},
0x44: {
name: 'cloner',
// TODO visual directions bitmask, no gameplay impact, possible editor impact
modifier: null,
},
0x45: {
name: 'hint',
},
0x46: {
name: 'force_floor_all',
},
// 0x47: 'button_gray',
// FIXME swivel floors... argh...
0x48: {
name: 'swivel_sw',
},
0x49: {
name: 'swivel_nw',
},
0x4a: {
name: 'swivel_ne',
},
0x4b: {
name: 'swivel_se',
},
0x4c: {
name: 'stopwatch_bonus',
has_next: true,
},
0x4d: {
name: 'stopwatch_toggle',
has_next: true,
},
0x4e: {
name: 'transmogrifier',
modifier: modifier_wire,
},
0x4f: {
name: 'railroad',
modifier: {
decode(tile, mask) {
// TODO railroad props
},
encode(tile) {
// TODO
return 0;
},
},
},
0x50: {
name: 'steel',
modifier: modifier_wire,
},
0x51: {
name: 'dynamite',
has_next: true,
},
0x52: {
name: 'helmet',
has_next: true,
},
0x56: {
name: 'player2',
has_next: true,
extra_args: [arg_direction],
},
// 0x57: Timid teeth : '#direction', '#next'
// 0x58: Explosion animation (unused in main levels) : '#direction', '#next'
0x59: {
name: 'hiking_boots',
has_next: true,
},
0x5a: {
name: 'no_player2_sign',
},
0x5b: {
name: 'no_player1_sign',
},
// 0x5c: Inverter gate (N) : Modifier allows other gates, see below
0x5e: {
name: 'button_pink',
modifier: modifier_wire,
},
0x5f: {
name: 'flame_jet_off',
},
0x60: {
name: 'flame_jet_on',
},
0x61: {
name: 'button_orange',
},
// 0x62: Lightning bolt : '#next'
0x63: {
name: 'tank_yellow',
has_next: true,
extra_args: [arg_direction],
},
0x64: {
name: 'button_yellow',
},
// 0x65: Mirror Chip : '#direction', '#next'
// 0x66: Mirror Melinda : '#direction', '#next'
0x68: {
name: 'bowling_ball',
has_next: true,
},
0x69: {
name: 'rover',
has_next: true,
extra_args: [arg_direction],
},
0x6a: {
name: 'stopwatch_penalty',
has_next: true,
},
0x6b: {
name: ['floor_custom_green', 'floor_custom_pink', 'floor_custom_yellow', 'floor_custom_blue'],
},
0x6d: {
// TODO oh this one is probably gonna be hard
name: '#thinwall/canopy',
has_next: true,
},
0x6f: {
name: 'railroad_sign',
has_next: true,
},
0x70: {
name: ['wall_custom_green', 'wall_custom_pink', 'wall_custom_yellow', 'wall_custom_blue'],
},
0x71: {
name: 'floor_letter',
modifier: {
decode(tile, ascii_code) {
tile.ascii_code = ascii_code;
},
encode(tile) {
return tile.ascii_code;
},
},
},
0x72: {
name: 'purple_wall',
},
0x73: {
name: 'purple_floor',
},
0x76: {
name: '#mod8',
},
0x77: {
name: '#mod16',
},
0x78: {
name: '#mod32',
},
0x7a: {
name: 'score_10',
has_next: true,
},
0x7b: {
name: 'score_100',
has_next: true,
},
0x7c: {
name: 'score_1000',
has_next: true,
},
0x7d: {
name: 'popdown_wall',
},
0x7e: {
name: 'popdown_floor',
},
0x7f: {
name: 'forbidden',
has_next: true,
},
0x80: {
name: 'score_2x',
has_next: true,
},
0x81: {
name: 'directional_block',
extra_args: [
arg_direction,
{
size: 1,
decode(tile, mask) {
let arrows = new Set;
for (let [direction, info] of Object.entries(DIRECTIONS)) {
if (mask & info.bit) {
arrows.add(direction);
}
}
tile.arrows = arrows;
},
encode(tile) {
// TODO
return 0;
},
},
],
has_next: true,
},
0x82: {
name: 'floor_mimic',
has_next: true,
extra_args: [arg_direction],
},
0x83: {
name: 'green_bomb',
has_next: true,
},
0x84: {
name: 'green_chip',
has_next: true,
},
0x87: {
name: 'button_black',
modifier: modifier_wire,
},
// 0x88: ON/OFF switch (OFF) :
// 0x89: ON/OFF switch (ON) :
0x8a: {
name: 'thief_keys',
},
0x8b: {
name: 'ghost',
has_next: true,
extra_args: [arg_direction],
},
0x8c: {
name: 'foil',
has_next: true,
},
0x8d: {
name: 'turtle',
},
0x8e: {
name: 'xray_eye',
has_next: true,
},
// 0x8f: Thief bribe : '#next'
// 0x90: Speed boots : '#next'
// 0x92: Hook : '#next'
};
const REVERSE_TILE_ENCODING = {};
for (let [tile_byte, spec] of Object.entries(TILE_ENCODING)) {
spec.tile_byte = tile_byte;
if (spec.name instanceof Array) {
// Custom floor/wall
for (let [i, name] of spec.name.entries()) {
// Copy the spec with a hardcoded modifier
let new_spec = Object.assign({}, spec);
new_spec.name = name;
new_spec.modifier = {
encode(tile) {
return i;
},
};
REVERSE_TILE_ENCODING[name] = new_spec;
}
}
else {
REVERSE_TILE_ENCODING[spec.name] = spec;
}
}
// Read 1, 2, or 4 bytes from a DataView
function read_n_bytes(view, start, n) {
if (n === 1) {
return view.getUint8(start, true);
}
else if (n === 2) {
return view.getUint16(start, true);
}
else if (n === 4) {
return view.getUint32(start, true);
}
else {
throw new Error(`Can't read ${n} bytes`);
}
}
// Decompress the little ad-hoc compression scheme used for both map data and
// solution playback
function decompress(buf) {
let decompressed_length = new DataView(buf).getUint16(0, true);
let out = new ArrayBuffer(decompressed_length);
let outbytes = new Uint8Array(out);
let bytes = new Uint8Array(buf);
let p = 2;
let q = 0;
while (p < buf.byteLength) {
let len = bytes[p];
p++;
if (len < 0x80) {
// Data block
outbytes.set(new Uint8Array(buf.slice(p, p + len)), q);
p += len;
q += len;
}
else {
// Back-reference block
len -= 0x80;
let offset = bytes[p];
p++;
// Can't use set + slice here because the copy can overlap and that
// doesn't work so great, so just do a regular loop and let the JIT
// deal with it
let start = q - offset;
for (let i = 0; i < len; i++) {
outbytes[q] = outbytes[start + i];
q++;
}
}
}
if (q !== decompressed_length)
throw new Error(`Expected to decode ${decompressed_length} bytes but got ${q} instead`);
return out;
}
export function parse_level(buf) {
let level = new util.StoredLevel;
let full_view = new DataView(buf);
let next_section_start = 0;
let extra_hints = [];
let hint_tiles = [];
while (next_section_start < buf.byteLength) {
// Read section header and length
let section_start = next_section_start;
let section_type = util.string_from_buffer_ascii(buf.slice(section_start, section_start + 4));
let section_length = full_view.getUint32(section_start + 4, true);
next_section_start = section_start + 8 + section_length;
if (next_section_start > buf.byteLength)
throw new Error(`Section at byte ${section_start} of type '${section_type}' extends ${buf.length - next_section_start} bytes past the end of the file`);
// This chunk marks the end of the file regardless
if (section_type === 'END ')
break;
if (section_type === 'CC2M' || section_type === 'LOCK' || section_type === 'VERS' ||
section_type === 'TITL' || section_type === 'AUTH' ||
section_type === 'CLUE' || section_type === 'NOTE')
{
// These are all singular strings (with a terminating NUL, for some reason)
// XXX character encoding??
let str = util.string_from_buffer_ascii(buf.slice(section_start + 8, next_section_start - 1)).replace(/\r\n/g, "\n");
// TODO store more of this, at least for idempotence, maybe
if (section_type === 'CC2M') {
// File version, doesn't seem interesting
}
else if (section_type === 'LOCK') {
// Unclear, seems to be a comment about the editor...?
}
else if (section_type === 'VERS') {
// Editor version which created this level
}
else if (section_type === 'TITL') {
// Level title
level.title = str;
}
else if (section_type === 'AUTH') {
// Author's name
level.author = str;
}
else if (section_type === 'CLUE') {
// Level hint
level.hint = str;
}
else if (section_type === 'NOTE') {
// Author's comments... but might also include multiple hints
// for levels with multiple hint tiles, delineated by [CLUE].
// For my purposes, extra hints are associated with the
// individual tiles, so we'll map those later
[level.comment, ...extra_hints] = str.split(/^\[CLUE\]$/mg);
}
continue;
}
let section_buf = buf.slice(section_start + 8, next_section_start);
let section_view = new DataView(buf, section_start + 8, section_length);
if (section_type === 'OPTN') {
// Level options, which may be truncated at any point
// TODO implement most of these
level.time_limit = section_view.getUint16(0, true);
// TODO 0 - 10x10, 1 - 9x9, 2 - split, otherwise unknown which needs handling
let viewport = section_view.getUint8(2, true);
if (viewport === 0) {
level.viewport_size = 10;
}
else if (viewport === 1) {
level.viewport_size = 9;
}
else if (viewport === 2) {
// FIXME this is split
level.viewport_size = 10;
}
else {
throw new Error(`Unrecognized viewport size option ${viewport}`);
}
if (section_view.byteLength <= 3)
continue;
//options.has_solution = section_view.getUint8(3, true);
if (section_view.byteLength <= 4)
continue;
//options.show_map_in_editor = section_view.getUint8(4, true);
if (section_view.byteLength <= 5)
continue;
//options.is_editable = section_view.getUint8(5, true);
if (section_view.byteLength <= 6)
continue;
//options.solution_hash = util.string_from_buffer_ascii(buf.slice(
//section_start + 6, section_start + 22));
if (section_view.byteLength <= 22)
continue;
//options.hide_logic = section_view.getUint8(22, true);
if (section_view.byteLength <= 23)
continue;
level.use_cc1_boots = section_view.getUint8(23, true);
if (section_view.byteLength <= 24)
continue;
//level.blob_behavior = section_view.getUint8(24, true);
}
else if (section_type === 'MAP ' || section_type === 'PACK') {
let data = section_buf;
if (section_type === 'PACK') {
data = decompress(data);
}
let bytes = new Uint8Array(data);
let map_view = new DataView(data);
let width = bytes[0];
let height = bytes[1];
level.size_x = width;
level.size_y = height;
let p = 2;
let n;
function read_spec() {
let tile_byte = bytes[p];
p++;
if (tile_byte === undefined)
throw new Error(`Read past end of file in cell ${n}`);
let spec = TILE_ENCODING[tile_byte];
if (! spec)
throw new Error(`Unrecognized tile type 0x${tile_byte.toString(16)}`);
return spec;
}
for (n = 0; n < width * height; n++) {
let cell = new util.StoredCell;
while (true) {
let spec = read_spec();
// Deal with modifiers
let modifier = 0; // defaults to zero
if (spec.name === '#mod8' || spec.name === '#mod16' || spec.name === '#mod32') {
if (spec.name === '#mod8') {
modifier = bytes[p];
p++;
}
else if (spec.name === '#mod16') {
modifier = map_view.getUint16(p, true);
p += 2;
}
else if (spec.name === '#mod32') {
modifier = map_view.getUint32(p, true);
p += 4;
}
spec = read_spec();
if (! spec.modifier) {
console.warn("Got unexpected modifier for tile:", spec.name);
}
}
let name = spec.name;
// Make a tile template, possibly dealing with some special cases
// FIXME restore this
if (name === '#thinwall/canopy') {
// Thin walls and the canopy are combined into a single byte for some
// reason; split them apart here. Which ones we get is determined by a
// bitmask
let mask = bytes[p];
p++;
// This order is important; this is the order CC2 draws them in
if (mask & 0x10) {
cell.push({type: TILE_TYPES['canopy']});
}
if (mask & 0x08) {
cell.push({type: TILE_TYPES['thinwall_w']});
}
if (mask & 0x04) {
cell.push({type: TILE_TYPES['thinwall_s']});
}
if (mask & 0x02) {
cell.push({type: TILE_TYPES['thinwall_e']});
}
if (mask & 0x01) {
cell.push({type: TILE_TYPES['thinwall_n']});
}
// Skip the rest of the loop. That means we don't handle any of the other
// special behavior below, but neither thin walls nor canopies should use
// any of it, so that's fine
continue;
}
else if (name instanceof Array) {
// Custom floors and walls are one of several options, chosen by modifier
name = name[modifier];
}
let type = TILE_TYPES[name];
if (!type) console.error(name, spec);
let tile = {type};
cell.push(tile);
if (spec.modifier) {
spec.modifier.decode(tile, modifier);
}
if (type.is_required_chip) {
level.chips_required++;
}
if (type.is_hint) {
// Remember all the hint tiles (in reading order) so we can map extra hints
// to them later. Don't do it now, since the format doesn't technically
// guarantee that the metadata sections appear before the map data!
hint_tiles.push(tile);
}
// Handle extra arguments
if (spec.extra_args) {
for (let argspec of spec.extra_args) {
let arg = read_n_bytes(map_view, p, argspec.size);
p += argspec.size;
argspec.decode(tile, arg);
}
}
if (! spec.has_next)
break;
}
cell.reverse();
level.linear_cells.push(cell);
}
}
else if (section_type === 'KEY ') {
}
else if (section_type === 'REPL' || section_type === 'PRPL') {
// "Replay", i.e. demo solution
let data = section_buf;
if (section_type === 'PRPL') {
data = decompress(data);
}
level.demo = new CC2Demo(data);
}
else if (section_type === 'RDNY') {
}
else if (section_type === 'END ') {
}
else {
console.warn(`Unrecognized section type '${section_type}' at offset ${section_start}`);
// TODO save it, persist when editing level
}
}
// Connect extra hints
let h = 0;
for (let tile of hint_tiles) {
if (h > extra_hints.length)
break;
tile.specific_hint = extra_hints[h];
h++;
}
return level;
}
// Write 1, 2, or 4 bytes to a DataView
function write_n_bytes(view, start, n, value) {
if (n === 1) {
view.setUint8(start, value, true);
}
else if (n === 2) {
view.setUint16(start, value, true);
}
else if (n === 4) {
view.setUint32(start, value, true);
}
else {
throw new Error(`Can't write ${n} bytes`);
}
}
class C2M {
constructor() {
this._sections = []; // array of [name, arraybuffer]
}
add_section(name, buf) {
if (name.length !== 4) {
throw new Error(`Section names must be four characters, not '${name}'`);
}
if (typeof buf === 'string' || buf instanceof String) {
let str = buf;
// C2M also includes the trailing NUL
buf = new ArrayBuffer(str.length + 1);
let array = new Uint8Array(buf);
for (let i = 0, l = str.length; i < l; i++) {
array[i] = str.charCodeAt(i);
}
}
this._sections.push([name, buf]);
}
serialize() {
let parts = [];
let total_length = 0;
for (let [name, buf] of this._sections) {
total_length += buf.byteLength + 8;
}
let ret = new ArrayBuffer(total_length);
let array = new Uint8Array(ret);
let view = new DataView(ret);
let p = 0;
for (let [name, buf] of this._sections) {
// Write the header
for (let i = 0; i < 4; i++) {
view.setUint8(p + i, name.charCodeAt(i));
}
view.setUint32(p + 4, buf.byteLength, true);
p += 8;
// Copy in the section contents
array.set(new Uint8Array(buf), p);
p += buf.byteLength;
}
console.log(this);
console.log(total_length);
console.log(array);
return ret;
}
}
export function synthesize_level(stored_level) {
let c2m = new C2M;
c2m.add_section('CC2M', '133');
// FIXME well this will not do
let map_bytes = new Uint8Array(4096);
let map_view = new DataView(map_bytes.buffer);
map_bytes[0] = stored_level.size_x;
map_bytes[1] = stored_level.size_y;
let p = 2;
console.log(stored_level);
for (let cell of stored_level.linear_cells) {
for (let i = cell.length - 1; i >= 0; i--) {
let tile = cell[i];
// FIXME does not yet support canopy or thin walls >:S
let spec = REVERSE_TILE_ENCODING[tile.type.name];
if (spec.modifier) {
let mod = spec.modifier.encode(tile);
if (mod === 0) {
// Zero is optional; do nothing
}
else if (mod < 256) {
// Encode in one byte
map_bytes[p] = REVERSE_TILE_ENCODING['#mod8'].tile_byte;
map_bytes[p + 1] = mod;
p += 2;
}
else if (mod < 65536) {
// Encode in two bytes
map_bytes[p] = REVERSE_TILE_ENCODING['#mod16'].tile_byte;
map_view.setUint16(p + 1, mod, true);
p += 3;
}
else {
// Encode in four (!) bytes
map_bytes[p] = REVERSE_TILE_ENCODING['#mod32'].tile_byte;
map_view.setUint16(p + 1, mod, true);
p += 5;
}
}
map_bytes[p] = spec.tile_byte;
p++;
if (spec.extra_args) {
for (let argspec of spec.extra_args) {
let arg = argspec.encode(tile);
write_n_bytes(map_view, p, argspec.size, arg);
p += argspec.size;
}
}
// TODO assert that the bottom tile has no next, and all the others do
}
}
// FIXME ack, ArrayBuffer.slice makes a copy actually! and i use it a lot in this file i think!!
c2m.add_section('MAP ', map_bytes.buffer.slice(0, p));
c2m.add_section('END ', '');
return c2m.serialize();
}