UNPKG

slp-enforcer

Version:

Finds violations of the Melee Controller Ruleset by inspecting SLP files

633 lines (632 loc) 26.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const ubjson_1 = require("@shelacek/ubjson"); // import fs from "fs"; const iconv_lite_1 = __importDefault(require("iconv-lite")); const mapValues_1 = __importDefault(require("lodash/mapValues")); const types_1 = require("../types"); const exists_1 = require("./exists"); const fullwidth_1 = require("./fullwidth"); var SlpInputSource; (function (SlpInputSource) { SlpInputSource["BUFFER"] = "buffer"; SlpInputSource["FILE"] = "file"; })(SlpInputSource = exports.SlpInputSource || (exports.SlpInputSource = {})); function getRef(input) { switch (input.source) { // case SlpInputSource.FILE: // if (!input.filePath) { // throw new Error("File source requires a file path"); // } // const fd = fs.openSync(input.filePath, "r"); // return { // source: input.source, // fileDescriptor: fd, // }; case SlpInputSource.BUFFER: return { source: input.source, buffer: input.buffer, }; default: throw new Error("Source type not supported"); } } function copy(src) { var dst = new ArrayBuffer(src.byteLength); new Uint8Array(dst).set(new Uint8Array(src)); return dst; } function readRef(ref, buffer, offset, length, position) { switch (ref.source) { // case SlpInputSource.FILE: // return fs.readSync(ref.fileDescriptor, buffer, offset, length, position); case SlpInputSource.BUFFER: if (position >= ref.buffer.byteLength) { return 0; } //"ref" is source // "buffer" is target // offset is start for "buffer" (target) // position is start for "ref" (source) // length is number of bytes to copy var sourceBuffer = ref.buffer.slice(position, position + length); buffer.set(new Uint8Array(sourceBuffer), offset); // return ref.buffer.copy(buffer, offset, position, position + length); return length; default: throw new Error("Source type not supported"); } } function getLenRef(ref) { switch (ref.source) { // case SlpInputSource.FILE: // const fileStats = fs.fstatSync(ref.fileDescriptor); // return fileStats.size; case SlpInputSource.BUFFER: return ref.buffer.byteLength; default: throw new Error("Source type not supported"); } } /** * Opens a file at path */ function openSlpFile(input) { const ref = getRef(input); const rawDataPosition = getRawDataPosition(ref); const rawDataLength = getRawDataLength(ref, rawDataPosition); const metadataPosition = rawDataPosition + rawDataLength + 10; // remove metadata string const metadataLength = getMetadataLength(ref, metadataPosition); const messageSizes = getMessageSizes(ref, rawDataPosition); return { ref, rawDataPosition, rawDataLength, metadataPosition, metadataLength, messageSizes, }; } exports.openSlpFile = openSlpFile; function closeSlpFile(file) { switch (file.ref.source) { // case SlpInputSource.FILE: // fs.closeSync(file.ref.fileDescriptor); // break; } } exports.closeSlpFile = closeSlpFile; // This function gets the position where the raw data starts function getRawDataPosition(ref) { const buffer = new Uint8Array(1); readRef(ref, buffer, 0, buffer.length, 0); if (buffer[0] === 0x36) { return 0; } if (buffer[0] !== "{".charCodeAt(0)) { return 0; // return error? } return 15; } function getRawDataLength(ref, position) { const fileSize = getLenRef(ref); if (position === 0) { return fileSize; } const buffer = new Uint8Array(4); readRef(ref, buffer, 0, buffer.length, position - 4); const rawDataLen = (buffer[0] << 24) | (buffer[1] << 16) | (buffer[2] << 8) | buffer[3]; if (rawDataLen > 0) { // If this method manages to read a number, it's probably trustworthy return rawDataLen; } // If the above does not return a valid data length, // return a file size based on file length. This enables // some support for severed files return fileSize - position; } function getMetadataLength(ref, position) { const len = getLenRef(ref); return len - position - 1; } function getMessageSizes(ref, position) { const messageSizes = {}; // Support old file format if (position === 0) { messageSizes[0x36] = 0x140; messageSizes[0x37] = 0x6; messageSizes[0x38] = 0x46; messageSizes[0x39] = 0x1; return messageSizes; } const buffer = new Uint8Array(2); readRef(ref, buffer, 0, buffer.length, position); if (buffer[0] !== types_1.Command.MESSAGE_SIZES) { return {}; } const payloadLength = buffer[1]; messageSizes[0x35] = payloadLength; const messageSizesBuffer = new Uint8Array(payloadLength - 1); readRef(ref, messageSizesBuffer, 0, messageSizesBuffer.length, position + 2); for (let i = 0; i < payloadLength - 1; i += 3) { const command = messageSizesBuffer[i]; // Get size of command messageSizes[command] = (messageSizesBuffer[i + 1] << 8) | messageSizesBuffer[i + 2]; } return messageSizes; } function getEnabledItems(view) { const offsets = [0x1, 0x100, 0x10000, 0x1000000, 0x100000000]; const enabledItems = offsets.reduce((acc, byteOffset, index) => { const byte = readUint8(view, 0x28 + index); return acc + byte * byteOffset; }, 0); return enabledItems; } function getGameInfoBlock(view) { const offset = 0x5; return { gameBitfield1: readUint8(view, 0x0 + offset), gameBitfield2: readUint8(view, 0x1 + offset), gameBitfield3: readUint8(view, 0x2 + offset), gameBitfield4: readUint8(view, 0x3 + offset), bombRainEnabled: (readUint8(view, 0x6 + offset) & 0xff) > 0 ? true : false, selfDestructScoreValue: readInt8(view, 0xc + offset), itemSpawnBitfield1: readUint8(view, 0x23 + offset), itemSpawnBitfield2: readUint8(view, 0x24 + offset), itemSpawnBitfield3: readUint8(view, 0x25 + offset), itemSpawnBitfield4: readUint8(view, 0x26 + offset), itemSpawnBitfield5: readUint8(view, 0x27 + offset), damageRatio: readFloat(view, 0x30 + offset), }; } /** * Iterates through slp events and parses payloads */ function iterateEvents(slpFile, callback, startPos = null) { var _a, _b, _c, _d; const ref = slpFile.ref; let readPosition = startPos !== null && startPos > 0 ? startPos : slpFile.rawDataPosition; const stopReadingAt = slpFile.rawDataPosition + slpFile.rawDataLength; // Generate read buffers for each const commandPayloadBuffers = mapValues_1.default(slpFile.messageSizes, (size) => new Uint8Array(size + 1)); let splitMessageBuffer = new Uint8Array(0); const commandByteBuffer = new Uint8Array(1); while (readPosition < stopReadingAt) { readRef(ref, commandByteBuffer, 0, 1, readPosition); let commandByte = (_a = commandByteBuffer[0]) !== null && _a !== void 0 ? _a : 0; let buffer = commandPayloadBuffers[commandByte]; if (buffer === undefined) { // If we don't have an entry for this command, return false to indicate failed read return readPosition; } if (buffer.length > stopReadingAt - readPosition) { return readPosition; } const advanceAmount = buffer.length; readRef(ref, buffer, 0, buffer.length, readPosition); if (commandByte === types_1.Command.SPLIT_MESSAGE) { // Here we have a split message, we will collect data from them until the last // message of the list is received const view = new DataView(buffer.buffer); const size = (_b = readUint16(view, 0x201)) !== null && _b !== void 0 ? _b : 512; const isLastMessage = readBool(view, 0x204); const internalCommand = (_c = readUint8(view, 0x203)) !== null && _c !== void 0 ? _c : 0; // If this is the first message, initialize the splitMessageBuffer // with the internal command byte because our parseMessage function // seems to expect a command byte at the start if (splitMessageBuffer.length === 0) { splitMessageBuffer = new Uint8Array(1); splitMessageBuffer[0] = internalCommand; } // Collect new data into splitMessageBuffer const appendBuf = buffer.slice(0x1, 0x1 + size); const mergedBuf = new Uint8Array(splitMessageBuffer.length + appendBuf.length); mergedBuf.set(splitMessageBuffer); mergedBuf.set(appendBuf, splitMessageBuffer.length); splitMessageBuffer = mergedBuf; if (isLastMessage) { commandByte = (_d = splitMessageBuffer[0]) !== null && _d !== void 0 ? _d : 0; buffer = splitMessageBuffer; splitMessageBuffer = new Uint8Array(0); } } const parsedPayload = parseMessage(commandByte, buffer); const shouldStop = callback(commandByte, parsedPayload, buffer); if (shouldStop) { break; } readPosition += advanceAmount; } return readPosition; } exports.iterateEvents = iterateEvents; function parseMessage(command, payload) { var _a, _b, _c; const view = new DataView(payload.buffer); switch (command) { case types_1.Command.GAME_START: const getPlayerObject = (playerIndex) => { // Controller Fix stuff const cfOffset = playerIndex * 0x8; const dashback = readUint32(view, 0x141 + cfOffset); const shieldDrop = readUint32(view, 0x145 + cfOffset); let controllerFix = "None"; if (dashback !== shieldDrop) { controllerFix = "Mixed"; } else if (dashback === 1) { controllerFix = "UCF"; } else if (dashback === 2) { controllerFix = "Dween"; } // Nametag stuff const nametagLength = 0x10; const nametagOffset = playerIndex * nametagLength; const nametagStart = 0x161 + nametagOffset; const nametagBuf = payload.slice(nametagStart, nametagStart + nametagLength); const nameTagString = iconv_lite_1.default .decode(nametagBuf, "Shift_JIS") .split("\0") .shift(); const nametag = nameTagString ? fullwidth_1.toHalfwidth(nameTagString) : ""; // Display name const displayNameLength = 0x1f; const displayNameOffset = playerIndex * displayNameLength; const displayNameStart = 0x1a5 + displayNameOffset; const displayNameBuf = payload.slice(displayNameStart, displayNameStart + displayNameLength); const displayNameString = iconv_lite_1.default .decode(displayNameBuf, "Shift_JIS") .split("\0") .shift(); const displayName = displayNameString ? fullwidth_1.toHalfwidth(displayNameString) : ""; // Connect code const connectCodeLength = 0xa; const connectCodeOffset = playerIndex * connectCodeLength; const connectCodeStart = 0x221 + connectCodeOffset; const connectCodeBuf = payload.slice(connectCodeStart, connectCodeStart + connectCodeLength); const connectCodeString = iconv_lite_1.default .decode(connectCodeBuf, "Shift_JIS") .split("\0") .shift(); const connectCode = connectCodeString ? fullwidth_1.toHalfwidth(connectCodeString) : ""; const userIdLength = 0x1d; const userIdOffset = playerIndex * userIdLength; const userIdStart = 0x249 + userIdOffset; const userIdBuf = payload.slice(userIdStart, userIdStart + userIdLength); const userIdString = iconv_lite_1.default .decode(userIdBuf, "utf8") .split("\0") .shift(); const userId = userIdString !== null && userIdString !== void 0 ? userIdString : ""; const offset = playerIndex * 0x24; const playerInfo = { playerIndex, port: playerIndex + 1, characterId: readUint8(view, 0x65 + offset), type: readUint8(view, 0x66 + offset), startStocks: readUint8(view, 0x67 + offset), characterColor: readUint8(view, 0x68 + offset), teamShade: readUint8(view, 0x6c + offset), handicap: readUint8(view, 0x6d + offset), teamId: readUint8(view, 0x6e + offset), staminaMode: Boolean(readUint8(view, 0x6c + playerIndex * 0x24, 0x01)), silentCharacter: Boolean(readUint8(view, 0x6c + playerIndex * 0x24, 0x02)), lowGravity: Boolean(readUint8(view, 0x6c + playerIndex * 0x24, 0x04)), invisible: Boolean(readUint8(view, 0x6c + playerIndex * 0x24, 0x08)), blackStockIcon: Boolean(readUint8(view, 0x6c + playerIndex * 0x24, 0x10)), metal: Boolean(readUint8(view, 0x6c + playerIndex * 0x24, 0x20)), startOnAngelPlatform: Boolean(readUint8(view, 0x6c + playerIndex * 0x24, 0x40)), rumbleEnabled: Boolean(readUint8(view, 0x6c + playerIndex * 0x24, 0x80)), cpuLevel: readUint8(view, 0x74 + offset), offenseRatio: readFloat(view, 0x7d + offset), defenseRatio: readFloat(view, 0x81 + offset), modelScale: readFloat(view, 0x85 + offset), controllerFix, nametag, displayName, connectCode, userId, }; return playerInfo; }; const matchIdLength = 51; const matchIdStart = 0x2be; const matchIdBuf = payload.slice(matchIdStart, matchIdStart + matchIdLength); const matchIdString = iconv_lite_1.default .decode(matchIdBuf, "utf8") .split("\0") .shift(); const matchId = matchIdString !== null && matchIdString !== void 0 ? matchIdString : ""; const gameSettings = { slpVersion: `${readUint8(view, 0x1)}.${readUint8(view, 0x2)}.${readUint8(view, 0x3)}`, timerType: readUint8(view, 0x5, 0x03), inGameMode: readUint8(view, 0x5, 0xe0), friendlyFireEnabled: !!readUint8(view, 0x6, 0x01), isTeams: readBool(view, 0xd), itemSpawnBehavior: readUint8(view, 0x10), stageId: readUint16(view, 0x13), startingTimerSeconds: readUint32(view, 0x15), enabledItems: getEnabledItems(view), players: [0, 1, 2, 3].map(getPlayerObject), scene: readUint8(view, 0x1a3), gameMode: readUint8(view, 0x1a4), language: readUint8(view, 0x2bd), gameInfoBlock: getGameInfoBlock(view), randomSeed: readUint32(view, 0x13d), isPAL: readBool(view, 0x1a1), isFrozenPS: readBool(view, 0x1a2), matchInfo: { matchId, gameNumber: readUint32(view, 0x2f1), tiebreakerNumber: readUint32(view, 0x2f5), }, }; return gameSettings; case types_1.Command.FRAME_START: return { frame: readInt32(view, 0x1), seed: readUint32(view, 0x5), sceneFrameCounter: readUint32(view, 0x9), }; case types_1.Command.PRE_FRAME_UPDATE: return { frame: readInt32(view, 0x1), playerIndex: readUint8(view, 0x5), isFollower: readBool(view, 0x6), seed: readUint32(view, 0x7), actionStateId: readUint16(view, 0xb), positionX: readFloat(view, 0xd), positionY: readFloat(view, 0x11), facingDirection: readFloat(view, 0x15), joystickX: readFloat(view, 0x19), joystickY: readFloat(view, 0x1d), cStickX: readFloat(view, 0x21), cStickY: readFloat(view, 0x25), trigger: readFloat(view, 0x29), buttons: readUint32(view, 0x2d), physicalButtons: readUint16(view, 0x31), physicalLTrigger: readFloat(view, 0x33), physicalRTrigger: readFloat(view, 0x37), rawJoystickX: readInt8(view, 0x3b), percent: readFloat(view, 0x3c), rawJoystickY: readInt8(view, 0x40), }; case types_1.Command.POST_FRAME_UPDATE: const selfInducedSpeeds = { airX: readFloat(view, 0x35), y: readFloat(view, 0x39), attackX: readFloat(view, 0x3d), attackY: readFloat(view, 0x41), groundX: readFloat(view, 0x45), }; return { frame: readInt32(view, 0x1), playerIndex: readUint8(view, 0x5), isFollower: readBool(view, 0x6), internalCharacterId: readUint8(view, 0x7), actionStateId: readUint16(view, 0x8), positionX: readFloat(view, 0xa), positionY: readFloat(view, 0xe), facingDirection: readFloat(view, 0x12), percent: readFloat(view, 0x16), shieldSize: readFloat(view, 0x1a), lastAttackLanded: readUint8(view, 0x1e), currentComboCount: readUint8(view, 0x1f), lastHitBy: readUint8(view, 0x20), stocksRemaining: readUint8(view, 0x21), actionStateCounter: readFloat(view, 0x22), miscActionState: readFloat(view, 0x2b), isAirborne: readBool(view, 0x2f), lastGroundId: readUint16(view, 0x30), jumpsRemaining: readUint8(view, 0x32), lCancelStatus: readUint8(view, 0x33), hurtboxCollisionState: readUint8(view, 0x34), selfInducedSpeeds: selfInducedSpeeds, hitlagRemaining: readFloat(view, 0x49), animationIndex: readUint32(view, 0x4d), instanceHitBy: readUint16(view, 0x51), instanceId: readUint16(view, 0x53), }; case types_1.Command.ITEM_UPDATE: return { frame: readInt32(view, 0x1), typeId: readUint16(view, 0x5), state: readUint8(view, 0x7), facingDirection: readFloat(view, 0x8), velocityX: readFloat(view, 0xc), velocityY: readFloat(view, 0x10), positionX: readFloat(view, 0x14), positionY: readFloat(view, 0x18), damageTaken: readUint16(view, 0x1c), expirationTimer: readFloat(view, 0x1e), spawnId: readUint32(view, 0x22), missileType: readUint8(view, 0x26), turnipFace: readUint8(view, 0x27), chargeShotLaunched: readUint8(view, 0x28), chargePower: readUint8(view, 0x29), owner: readInt8(view, 0x2a), instanceId: readUint16(view, 0x2b), }; case types_1.Command.FRAME_BOOKEND: return { frame: readInt32(view, 0x1), latestFinalizedFrame: readInt32(view, 0x5), }; case types_1.Command.GAME_END: const placements = [0, 1, 2, 3].map((playerIndex) => { const position = readInt8(view, 0x3 + playerIndex); return { playerIndex, position }; }); return { gameEndMethod: readUint8(view, 0x1), lrasInitiatorIndex: readInt8(view, 0x2), placements, }; case types_1.Command.GECKO_LIST: const codes = []; let pos = 1; while (pos < payload.length) { const word1 = (_a = readUint32(view, pos)) !== null && _a !== void 0 ? _a : 0; const codetype = (word1 >> 24) & 0xfe; const address = (word1 & 0x01ffffff) + 0x80000000; let offset = 8; // Default code length, most codes are this length if (codetype === 0xc0 || codetype === 0xc2) { const lineCount = (_b = readUint32(view, pos + 4)) !== null && _b !== void 0 ? _b : 0; offset = 8 + lineCount * 8; } else if (codetype === 0x06) { const byteLen = (_c = readUint32(view, pos + 4)) !== null && _c !== void 0 ? _c : 0; offset = 8 + ((byteLen + 7) & 0xfffffff8); } else if (codetype === 0x08) { offset = 16; } codes.push({ type: codetype, address: address, contents: payload.slice(pos, pos + offset), }); pos += offset; } return { contents: payload.slice(1), codes: codes, }; default: return null; } } exports.parseMessage = parseMessage; function canReadFromView(view, offset, length) { const viewLength = view.byteLength; return offset + length <= viewLength; } function readFloat(view, offset) { if (!canReadFromView(view, offset, 4)) { return null; } return view.getFloat32(offset); } function readInt32(view, offset) { if (!canReadFromView(view, offset, 4)) { return null; } return view.getInt32(offset); } function readInt8(view, offset) { if (!canReadFromView(view, offset, 1)) { return null; } return view.getInt8(offset); } function readUint32(view, offset) { if (!canReadFromView(view, offset, 4)) { return null; } return view.getUint32(offset); } function readUint16(view, offset) { if (!canReadFromView(view, offset, 2)) { return null; } return view.getUint16(offset); } function readUint8(view, offset, bitmask = 0xff) { if (!canReadFromView(view, offset, 1)) { return null; } return view.getUint8(offset) & bitmask; } function readBool(view, offset) { if (!canReadFromView(view, offset, 1)) { return null; } return !!view.getUint8(offset); } function getMetadata(slpFile) { if (slpFile.metadataLength <= 0) { // This will happen on a severed incomplete file // $FlowFixMe return null; } const buffer = new Uint8Array(slpFile.metadataLength); readRef(slpFile.ref, buffer, 0, buffer.length, slpFile.metadataPosition); let metadata = null; try { metadata = ubjson_1.decode(buffer); } catch (ex) { // Do nothing // console.log(ex); } // $FlowFixMe return metadata; } exports.getMetadata = getMetadata; function getGameEnd(slpFile) { const { ref, rawDataPosition, rawDataLength, messageSizes } = slpFile; const gameEndPayloadSize = messageSizes[types_1.Command.GAME_END]; if (!exists_1.exists(gameEndPayloadSize) || gameEndPayloadSize <= 0) { return null; } // Add one to account for command byte const gameEndSize = gameEndPayloadSize + 1; const gameEndPosition = rawDataPosition + rawDataLength - gameEndSize; const buffer = new Uint8Array(gameEndSize); readRef(ref, buffer, 0, buffer.length, gameEndPosition); if (buffer[0] !== types_1.Command.GAME_END) { // This isn't even a game end payload return null; } const gameEndMessage = parseMessage(types_1.Command.GAME_END, buffer); if (!gameEndMessage) { return null; } return gameEndMessage; } exports.getGameEnd = getGameEnd; function extractFinalPostFrameUpdates(slpFile) { const { ref, rawDataPosition, rawDataLength, messageSizes } = slpFile; // The following should exist on all replay versions const postFramePayloadSize = messageSizes[types_1.Command.POST_FRAME_UPDATE]; const gameEndPayloadSize = messageSizes[types_1.Command.GAME_END]; const frameBookendPayloadSize = messageSizes[types_1.Command.FRAME_BOOKEND]; // Technically this should not be possible if (!exists_1.exists(postFramePayloadSize)) { return []; } const gameEndSize = gameEndPayloadSize ? gameEndPayloadSize + 1 : 0; const postFrameSize = postFramePayloadSize + 1; const frameBookendSize = frameBookendPayloadSize ? frameBookendPayloadSize + 1 : 0; let frameNum = null; let postFramePosition = rawDataPosition + rawDataLength - gameEndSize - frameBookendSize - postFrameSize; const postFrameUpdates = []; do { const buffer = new Uint8Array(postFrameSize); readRef(ref, buffer, 0, buffer.length, postFramePosition); if (buffer[0] !== types_1.Command.POST_FRAME_UPDATE) { break; } const postFrameMessage = parseMessage(types_1.Command.POST_FRAME_UPDATE, buffer); if (!postFrameMessage) { break; } if (frameNum === null) { frameNum = postFrameMessage.frame; } else if (frameNum !== postFrameMessage.frame) { // If post frame message is found but the frame doesn't match, it's not part of the final frame break; } postFrameUpdates.unshift(postFrameMessage); postFramePosition -= postFrameSize; } while (postFramePosition >= rawDataPosition); return postFrameUpdates; } exports.extractFinalPostFrameUpdates = extractFinalPostFrameUpdates;