UNPKG

@slippi/slippi-js

Version:
252 lines (249 loc) 9.59 kB
import fs from 'fs'; import forEach from 'lodash/forEach.js'; import { Writable } from 'stream'; import { Command } from '../../common/types.esm.js'; import { SlpStream, SlpStreamMode, SlpStreamEvent } from '../../common/utils/slpStream.esm.js'; const DEFAULT_NICKNAME = "unknown"; /** * SlpFile is a class that wraps a Writable stream. It handles the writing of the binary * header and footer, and also handles the overwriting of the raw data length. * * @class SlpFile * @extends {Writable} */ class SlpFile extends Writable { /** * Creates an instance of SlpFile. * @param {string} filePath The file location to write to. * @param {WritableOptions} [opts] Options for writing. * @memberof SlpFile */ constructor(filePath, slpStream, opts) { super(opts); this.rawDataLength = 0; this.usesExternalStream = false; this.filePath = filePath; this.metadata = { consoleNickname: DEFAULT_NICKNAME, startTime: new Date(), lastFrame: -124, players: {}, }; this.usesExternalStream = Boolean(slpStream); // Create a new SlpStream if one wasn't already provided // This SLP stream represents a single game not multiple, so use manual mode this.slpStream = slpStream ? slpStream : new SlpStream({ mode: SlpStreamMode.MANUAL }); this._setupListeners(); this._initializeNewGame(this.filePath); } /** * Get the current file path being written to. * * @returns {string} The location of the current file path * @memberof SlpFile */ path() { return this.filePath; } /** * Sets the metadata of the Slippi file, such as consoleNickname, lastFrame, and players. * @param metadata The metadata to be written */ setMetadata(metadata) { this.metadata = Object.assign({}, this.metadata, metadata); } _write(chunk, encoding, callback) { if (encoding !== "buffer") { throw new Error(`Unsupported stream encoding. Expected 'buffer' got '${encoding}'.`); } // Write it to the file if (this.fileStream) { this.fileStream.write(chunk); } // Parse the data manually if it's an internal stream if (!this.usesExternalStream) { this.slpStream.process(chunk); } // Keep track of the bytes we've written this.rawDataLength += chunk.length; callback(); } /** * Here we define what to do on each command. We need to populate the metadata field * so we keep track of the latest frame, as well as the number of frames each character has * been used. * * @param data The parsed data from a SlpStream */ _onCommand(data) { const { command, payload } = data; switch (command) { case Command.GAME_START: const { players } = payload; forEach(players, (player) => { if (player.type === 3) { return; } this.metadata.players[player.playerIndex] = { characterUsage: {}, names: { netplay: player.displayName, code: player.connectCode, }, }; }); break; case Command.POST_FRAME_UPDATE: // Here we need to update some metadata fields const { frame, playerIndex, isFollower, internalCharacterId } = payload; if (isFollower) { // No need to do this for follower break; } // Update frame index this.metadata.lastFrame = frame; // Update character usage const prevPlayer = this.metadata.players[playerIndex]; const characterUsage = prevPlayer.characterUsage; const curCharFrames = characterUsage[internalCharacterId] || 0; const player = Object.assign(Object.assign({}, prevPlayer), { characterUsage: Object.assign(Object.assign({}, characterUsage), { [internalCharacterId]: curCharFrames + 1 }) }); this.metadata.players[playerIndex] = player; break; } } _setupListeners() { const streamListener = (data) => { this._onCommand(data); }; this.slpStream.on(SlpStreamEvent.COMMAND, streamListener); this.on("finish", () => { // Update file with bytes written const fd = fs.openSync(this.filePath, "r+"); fs.writeSync(fd, createUInt32Buffer(this.rawDataLength), 0, 4, 11); fs.closeSync(fd); // Unsubscribe from the stream this.slpStream.removeListener(SlpStreamEvent.COMMAND, streamListener); // Clean up the internal processor if (!this.usesExternalStream) { this.slpStream.restart(); } }); } _initializeNewGame(filePath) { this.fileStream = fs.createWriteStream(filePath, { encoding: "binary", }); const header = Buffer.concat([ Buffer.from("{U"), Buffer.from([3]), Buffer.from("raw[$U#l"), Buffer.from([0, 0, 0, 0]), ]); this.fileStream.write(header); } _final(callback) { let footer = Buffer.concat([Buffer.from("U"), Buffer.from([8]), Buffer.from("metadata{")]); // Write game start time const startTimeStr = this.metadata.startTime.toISOString(); footer = Buffer.concat([ footer, Buffer.from("U"), Buffer.from([7]), Buffer.from("startAtSU"), Buffer.from([startTimeStr.length]), Buffer.from(startTimeStr), ]); // Write last frame index // TODO: Get last frame const lastFrame = this.metadata.lastFrame; footer = Buffer.concat([ footer, Buffer.from("U"), Buffer.from([9]), Buffer.from("lastFramel"), createInt32Buffer(lastFrame), ]); // write the Console Nickname const consoleNick = this.metadata.consoleNickname || DEFAULT_NICKNAME; footer = Buffer.concat([ footer, Buffer.from("U"), Buffer.from([11]), Buffer.from("consoleNickSU"), Buffer.from([consoleNick.length]), Buffer.from(consoleNick), ]); // Start writting player specific data footer = Buffer.concat([footer, Buffer.from("U"), Buffer.from([7]), Buffer.from("players{")]); const players = this.metadata.players; forEach(players, (player, index) => { // Start player obj with index being the player index footer = Buffer.concat([footer, Buffer.from("U"), Buffer.from([index.length]), Buffer.from(`${index}{`)]); // Start characters key for this player footer = Buffer.concat([footer, Buffer.from("U"), Buffer.from([10]), Buffer.from("characters{")]); // Write character usage forEach(player.characterUsage, (usage, internalId) => { // Write this character footer = Buffer.concat([ footer, Buffer.from("U"), Buffer.from([internalId.length]), Buffer.from(`${internalId}l`), createUInt32Buffer(usage), ]); }); // Close characters footer = Buffer.concat([footer, Buffer.from("}")]); // Start names key for this player footer = Buffer.concat([footer, Buffer.from("U"), Buffer.from([5]), Buffer.from("names{")]); // Write display name footer = Buffer.concat([ footer, Buffer.from("U"), Buffer.from([7]), Buffer.from("netplaySU"), Buffer.from([player.names.netplay.length]), Buffer.from(`${player.names.netplay}`), ]); // Write connect code footer = Buffer.concat([ footer, Buffer.from("U"), Buffer.from([4]), Buffer.from("codeSU"), Buffer.from([player.names.code.length]), Buffer.from(`${player.names.code}`), ]); // Close names and player footer = Buffer.concat([footer, Buffer.from("}}")]); }); // Close players footer = Buffer.concat([footer, Buffer.from("}")]); // Write played on footer = Buffer.concat([ footer, Buffer.from("U"), Buffer.from([8]), Buffer.from("playedOnSU"), Buffer.from([7]), Buffer.from("network"), ]); // Close metadata and file footer = Buffer.concat([footer, Buffer.from("}}")]); // End the stream if (this.fileStream) { this.fileStream.end(footer, callback); } } } const createInt32Buffer = (number) => { const buf = Buffer.alloc(4); buf.writeInt32BE(number, 0); return buf; }; const createUInt32Buffer = (number) => { const buf = Buffer.alloc(4); buf.writeUInt32BE(number, 0); return buf; }; export { SlpFile };