• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

project-slippi / slippi-js / 26955288738

04 Jun 2026 01:37PM UTC coverage: 81.034% (+0.8%) from 80.188%
26955288738

Pull #180

github

web-flow
Merge 07f9df91d into 142a21c38
Pull Request #180: refactor: Remove lodash

871 of 1156 branches covered (75.35%)

Branch coverage included in aggregate %.

160 of 165 new or added lines in 10 files covered. (96.97%)

18 existing lines in 3 files now uncovered.

1949 of 2324 relevant lines covered (83.86%)

320353.83 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.72
/src/node/utils/slpFile.ts
1
import type { WriteStream } from "fs";
2
import fs from "fs";
26✔
3

4
import type { WritableOptions } from "stream";
5
import { Writable } from "stream";
26✔
6

7
import type { GameStartType, PostFrameUpdateType } from "../../common/types.js";
8
import { Command } from "../../common/types.js";
26✔
9
import type { SlpCommandEventPayload } from "../../common/utils/slpStream.js";
10
import { SlpStream, SlpStreamEvent, SlpStreamMode } from "../../common/utils/slpStream.js";
26✔
11

12
const DEFAULT_NICKNAME = "unknown";
26✔
13

14
export type SlpFileMetadata = {
15
  startTime: Date;
16
  lastFrame: number;
17
  players: {
18
    [playerIndex: number]: {
19
      characterUsage: {
20
        [internalCharacterId: number]: number;
21
      };
22
      names: {
23
        netplay: string;
24
        code: string;
25
      };
26
    };
27
  };
28
  consoleNickname?: string;
29
};
30

31
/**
32
 * SlpFile is a class that wraps a Writable stream. It handles the writing of the binary
33
 * header and footer, and also handles the overwriting of the raw data length.
34
 *
35
 * @class SlpFile
36
 * @extends {Writable}
37
 */
38
export class SlpFile extends Writable {
26✔
39
  private filePath: string;
40
  private metadata: SlpFileMetadata;
41
  private fileStream?: WriteStream;
42
  private rawDataLength = 0;
6✔
43
  private slpStream: SlpStream;
44
  private usesExternalStream = false;
6✔
45

46
  /**
47
   * Creates an instance of SlpFile.
48
   * @param {string} filePath The file location to write to.
49
   * @param {WritableOptions} [opts] Options for writing.
50
   * @memberof SlpFile
51
   */
52
  public constructor(filePath: string, slpStream?: SlpStream, opts?: WritableOptions) {
53
    super(opts);
6✔
54
    this.filePath = filePath;
6✔
55
    this.metadata = {
6✔
56
      consoleNickname: DEFAULT_NICKNAME,
57
      startTime: new Date(),
58
      lastFrame: -124,
59
      players: {},
60
    };
61
    this.usesExternalStream = Boolean(slpStream);
6✔
62

63
    // Create a new SlpStream if one wasn't already provided
64
    // This SLP stream represents a single game not multiple, so use manual mode
65
    this.slpStream = slpStream ? slpStream : new SlpStream({ mode: SlpStreamMode.MANUAL });
6!
66

67
    this._setupListeners();
6✔
68
    this._initializeNewGame(this.filePath);
6✔
69
  }
70

71
  /**
72
   * Get the current file path being written to.
73
   *
74
   * @returns {string} The location of the current file path
75
   * @memberof SlpFile
76
   */
77
  public path(): string {
78
    return this.filePath;
12✔
79
  }
80

81
  /**
82
   * Sets the metadata of the Slippi file, such as consoleNickname, lastFrame, and players.
83
   * @param metadata The metadata to be written
84
   */
85
  public setMetadata(metadata: Partial<SlpFileMetadata>): void {
86
    this.metadata = Object.assign({}, this.metadata, metadata);
6✔
87
  }
88

89
  public _write(chunk: Uint8Array, encoding: string, callback: (error?: Error | null) => void): void {
90
    if (encoding !== "buffer") {
672,744!
UNCOV
91
      throw new Error(`Unsupported stream encoding. Expected 'buffer' got '${encoding}'.`);
×
92
    }
93
    // Write it to the file
94
    if (this.fileStream) {
672,744✔
95
      this.fileStream.write(chunk);
672,744✔
96
    }
97

98
    // Parse the data manually if it's an internal stream
99
    if (!this.usesExternalStream) {
672,744!
UNCOV
100
      this.slpStream.process(chunk);
×
101
    }
102

103
    // Keep track of the bytes we've written
104
    this.rawDataLength += chunk.length;
672,744✔
105
    callback();
672,744✔
106
  }
107

108
  /**
109
   * Here we define what to do on each command. We need to populate the metadata field
110
   * so we keep track of the latest frame, as well as the number of frames each character has
111
   * been used.
112
   *
113
   * @param data The parsed data from a SlpStream
114
   */
115
  private _onCommand(data: SlpCommandEventPayload): void {
116
    const { command, payload } = data;
672,344✔
117
    switch (command) {
672,344✔
118
      case Command.GAME_START:
119
        const { players } = payload as GameStartType;
6✔
120
        players.forEach((player) => {
6✔
121
          if (player.type === 3) {
24✔
122
            return;
8✔
123
          }
124

125
          this.metadata.players[player.playerIndex] = {
16✔
126
            characterUsage: {},
127
            names: {
128
              netplay: player.displayName,
129
              code: player.connectCode,
130
            },
131
          };
132
        });
133
        break;
6✔
134
      case Command.POST_FRAME_UPDATE:
135
        // Here we need to update some metadata fields
136
        const { frame, playerIndex, isFollower, internalCharacterId } = payload as PostFrameUpdateType;
236,096✔
137
        if (isFollower) {
236,096!
138
          // No need to do this for follower
UNCOV
139
          break;
×
140
        }
141

142
        // Update frame index
143
        this.metadata.lastFrame = frame!;
236,096✔
144

145
        // Update character usage
146
        const prevPlayer = this.metadata.players[playerIndex!];
236,096✔
147
        const characterUsage = prevPlayer!.characterUsage;
236,096✔
148
        const curCharFrames = characterUsage[internalCharacterId!] || 0;
236,096✔
149
        const player = {
236,096✔
150
          ...prevPlayer,
151
          characterUsage: {
152
            ...characterUsage,
153
            [internalCharacterId!]: curCharFrames + 1,
154
          },
155
        };
156
        (this.metadata.players as any)[playerIndex!] = player;
236,096✔
157
        break;
236,096✔
158
    }
159
  }
160

161
  private _setupListeners(): void {
162
    const streamListener = (data: SlpCommandEventPayload): void => {
6✔
163
      this._onCommand(data);
672,344✔
164
    };
165
    this.slpStream.on(SlpStreamEvent.COMMAND, streamListener);
6✔
166

167
    this.on("finish", () => {
6✔
168
      // Update file with bytes written
169
      const fd = fs.openSync(this.filePath, "r+");
6✔
170
      fs.writeSync(fd, createUInt32Buffer(this.rawDataLength), 0, 4, 11);
6✔
171
      fs.closeSync(fd);
6✔
172

173
      // Unsubscribe from the stream
174
      this.slpStream.removeListener(SlpStreamEvent.COMMAND, streamListener);
6✔
175
      // Clean up the internal processor
176
      if (!this.usesExternalStream) {
6!
UNCOV
177
        this.slpStream.restart();
×
178
      }
179
    });
180
  }
181

182
  private _initializeNewGame(filePath: string): void {
183
    this.fileStream = fs.createWriteStream(filePath, {
6✔
184
      encoding: "binary",
185
    });
186

187
    const header = Buffer.concat([
6✔
188
      Buffer.from("{U"),
189
      Buffer.from([3]),
190
      Buffer.from("raw[$U#l"),
191
      Buffer.from([0, 0, 0, 0]),
192
    ]);
193
    this.fileStream.write(header);
6✔
194
  }
195

196
  public _final(callback: (error?: Error | null) => void): void {
197
    let footer = Buffer.concat([Buffer.from("U"), Buffer.from([8]), Buffer.from("metadata{")]);
6✔
198

199
    // Write game start time
200
    const startTimeStr = this.metadata.startTime.toISOString();
6✔
201
    footer = Buffer.concat([
6✔
202
      footer,
203
      Buffer.from("U"),
204
      Buffer.from([7]),
205
      Buffer.from("startAtSU"),
206
      Buffer.from([startTimeStr.length]),
207
      Buffer.from(startTimeStr),
208
    ]);
209

210
    // Write last frame index
211
    // TODO: Get last frame
212
    const lastFrame = this.metadata.lastFrame;
6✔
213
    footer = Buffer.concat([
6✔
214
      footer,
215
      Buffer.from("U"),
216
      Buffer.from([9]),
217
      Buffer.from("lastFramel"),
218
      createInt32Buffer(lastFrame),
219
    ]);
220

221
    // write the Console Nickname
222
    const consoleNick = this.metadata.consoleNickname || DEFAULT_NICKNAME;
6!
223
    footer = Buffer.concat([
6✔
224
      footer,
225
      Buffer.from("U"),
226
      Buffer.from([11]),
227
      Buffer.from("consoleNickSU"),
228
      Buffer.from([consoleNick.length]),
229
      Buffer.from(consoleNick),
230
    ]);
231

232
    // Start writting player specific data
233
    footer = Buffer.concat([footer, Buffer.from("U"), Buffer.from([7]), Buffer.from("players{")]);
6✔
234
    const players = this.metadata.players;
6✔
235
    Object.entries(players).forEach(([index, player]) => {
6✔
236
      // Start player obj with index being the player index
237
      footer = Buffer.concat([footer, Buffer.from("U"), Buffer.from([index.length]), Buffer.from(`${index}{`)]);
16✔
238

239
      // Start characters key for this player
240
      footer = Buffer.concat([footer, Buffer.from("U"), Buffer.from([10]), Buffer.from("characters{")]);
16✔
241

242
      // Write character usage
243
      Object.entries(player.characterUsage).forEach(([internalId, usage]) => {
16✔
244
        // Write this character
245
        footer = Buffer.concat([
16✔
246
          footer,
247
          Buffer.from("U"),
248
          Buffer.from([internalId.length]),
249
          Buffer.from(`${internalId}l`),
250
          createUInt32Buffer(usage),
251
        ]);
252
      });
253

254
      // Close characters
255
      footer = Buffer.concat([footer, Buffer.from("}")]);
16✔
256

257
      // Start names key for this player
258
      footer = Buffer.concat([footer, Buffer.from("U"), Buffer.from([5]), Buffer.from("names{")]);
16✔
259

260
      // Write display name
261
      footer = Buffer.concat([
16✔
262
        footer,
263
        Buffer.from("U"),
264
        Buffer.from([7]),
265
        Buffer.from("netplaySU"),
266
        Buffer.from([player.names.netplay.length]),
267
        Buffer.from(`${player.names.netplay}`),
268
      ]);
269

270
      // Write connect code
271
      footer = Buffer.concat([
16✔
272
        footer,
273
        Buffer.from("U"),
274
        Buffer.from([4]),
275
        Buffer.from("codeSU"),
276
        Buffer.from([player.names.code.length]),
277
        Buffer.from(`${player.names.code}`),
278
      ]);
279

280
      // Close names and player
281
      footer = Buffer.concat([footer, Buffer.from("}}")]);
16✔
282
    });
283

284
    // Close players
285
    footer = Buffer.concat([footer, Buffer.from("}")]);
6✔
286

287
    // Write played on
288
    footer = Buffer.concat([
6✔
289
      footer,
290
      Buffer.from("U"),
291
      Buffer.from([8]),
292
      Buffer.from("playedOnSU"),
293
      Buffer.from([7]),
294
      Buffer.from("network"),
295
    ]);
296

297
    // Close metadata and file
298
    footer = Buffer.concat([footer, Buffer.from("}}")]);
6✔
299

300
    // End the stream
301
    if (this.fileStream) {
6✔
302
      this.fileStream.end(footer, callback);
6✔
303
    }
304
  }
305
}
306

307
const createInt32Buffer = (number: number): Buffer => {
26✔
308
  const buf = Buffer.alloc(4);
6✔
309
  buf.writeInt32BE(number, 0);
6✔
310
  return buf;
6✔
311
};
312

313
const createUInt32Buffer = (number: number): Buffer => {
26✔
314
  const buf = Buffer.alloc(4);
22✔
315
  buf.writeUInt32BE(number, 0);
22✔
316
  return buf;
22✔
317
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc