roomie 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,71 +2,107 @@
2
2
 
3
3
  ![GitHub Sponsors](https://img.shields.io/github/sponsors/nikitacontreras?style=flat-square&label=sponsor%20me&link=https%3A%2F%2Fgithub.com%2Fsponsors%2Fnikitacontreras) ![NPM Version](https://img.shields.io/npm/v/roomie?style=flat-square)
4
4
 
5
+ ---
5
6
 
7
+ ## Introduction
6
8
 
7
- Roomie is a library for analyzing basic metadata of ROM files from various classic consoles. It allows extracting relevant information such as the game name, region, code, ROM and RAM size, version, and other console-specific data.
9
+ **roomie** is a powerful and lightweight library for extracting metadata from ROM files of classic gaming consoles. It supports multiple systems, handles **NES 2.0** headers, calculates **CRC32** and **SHA1** hashes, and can even read ROMs directly from **ZIP** archives.
10
+
11
+ Designed for simplicity and accuracy, **roomie** identifies systems via header magic words or file extensions and provides detailed information about mappers, co-processors, regions, and save types.
12
+
13
+ ---
14
+
15
+ ## Features
16
+
17
+ - **ZIP Support**: Load ROMs directly from `.zip` files.
18
+ - **Hardware Detection**: Identifies NES Mappers, GB MBCs, SNES Co-processors, and GBA Save types.
19
+ - **NES 2.0**: Full support for modern NES header specifications.
20
+ - **Hashing**: Built-in SHA1 and CRC32 calculation.
21
+ - **Exports**: Generate `JSON` or `Gamelist XML` (EmulationStation compatible) strings.
22
+
23
+ ---
8
24
 
9
25
  ## Installation
10
26
 
27
+ Install via npm:
28
+
11
29
  ```bash
12
30
  npm install roomie
13
31
  ```
14
32
 
33
+ ---
34
+
15
35
  ## Usage
16
36
 
17
- Roomie supports both CommonJS and ES Module import styles depending on your project setup.
37
+ **roomie** supports both CommonJS (CJS) and ES Modules (ESM).
18
38
 
19
- ### CommonJS (CJS)
39
+ ### Basic Example (Async/Await)
20
40
 
21
- ```js
22
- const { Roomie } = require('roomie');
41
+ ```ts
42
+ import Roomie from "roomie";
23
43
 
24
- const romPath = "/path/to/game.sfc";
25
- const roomie = new Roomie(romPath);
44
+ const roomie = new Roomie();
26
45
 
27
- roomie.on("loaded", (info) => {
28
- console.log("ROM Information:", info);
29
- });
46
+ // Load from a file path (supports .zip)
47
+ await roomie.load("./Super Mario World.zip");
48
+
49
+ console.log(roomie.info);
50
+ /*
51
+ {
52
+ system: 'sfc',
53
+ hash: { sha1: '...', crc32: 'b19ed489' },
54
+ sfc: { rom: { type: 'LoROM', size: 2048000 }, ram: 8000 },
55
+ ...
56
+ }
57
+ */
58
+
59
+ // Export to EmulationStation gamelist format
60
+ console.log(roomie.toGamelistXML());
30
61
  ```
31
62
 
32
- ### ES Modules (ESM)
63
+ ---
33
64
 
34
- ```ts
35
- import Roomie from "roomie";
65
+ ## Supported Consoles
36
66
 
37
- const romPath = "/path/to/game.sfc";
38
- const roomie = new Roomie(romPath);
67
+ | Console | System Key | Description |
68
+ |---------|------------|-------------|
69
+ | **NES / Famicom** | `nes` | iNES & **NES 2.0** support. Detects Mappers (MMC1, MMC3, etc). |
70
+ | **Super Nintendo** | `sfc` | Detects LoROM/HiROM, Co-processors (SuperFX, SA-1, DSP). |
71
+ | **Nintendo 64** | `n64` | Header parsing, region detection, and byte-swapping support. |
72
+ | **Game Boy / Color** | `gb` | Detects MBC types, RAM size, and GBC/SGB features. |
73
+ | **Game Boy Advance** | `gba` | Extract Game ID and identifies Save Type (SRAM/Flash/EEPROM). |
74
+ | **Nintendo DS** | `nds` | Game code, region, unit code (DSi), and device capacity. |
75
+ | **Sega Genesis** | `genesis` | Reads Domestic/Overseas names, serials, and regions. |
76
+ | **Master System / GG** | `sms` / `gg` | Header detection (TMR SEGA), product code, and region. |
77
+ | **WonderSwan / Color** | `ws` / `wsc` | End-of-ROM header parsing for game ID and model. |
78
+ | **PC Engine** | `pce` | Basic identification for TurboGrafx-16 systems. |
39
79
 
40
- roomie.on("loaded", (info) => {
41
- console.log("ROM Information:", info);
42
- });
80
+ ---
43
81
 
44
- await roomie.load(romPath); // Load ROM from file path
82
+ ## API Reference
45
83
 
46
- const romBuffer = Buffer.from([...]); // Load ROM from a Buffer
47
- await roomie.load(romBuffer);
84
+ ### Methods
48
85
 
49
- console.log(roomie.info);
50
- ```
86
+ - **`await load(pathOrBuffer: string | Buffer)`**: Loads a ROM. If it's a ZIP, it extracts the first ROM entry.
87
+ - **`toJSON(): string`**: Returns the `RomInfo` object as a formatted JSON string.
88
+ - **`toGamelistXML(): string`**: Returns an XML string compatible with EmulationStation's `gamelist.xml`.
51
89
 
52
- ## Supported Consoles
90
+ ### Properties
53
91
 
54
- Roomie supports metadata extraction from the following systems:
92
+ - **`info: RomInfo`**: Comprehensive metadata object.
93
+ - **`system: string`**: The detected system key (e.g., `"sfc"`, `"nes"`).
94
+ - **`hash: { sha1, crc32 }`**: Calculated hashes of the ROM (excluding ZIP overhead).
95
+ - **`cartridge`**: System-specific details (Mappers, MBC, Save Types).
55
96
 
56
- - **Nintendo DS (NDS):** Retrieves data such as the game name, region, game code, ROM and RAM size, version, among others.
57
- - **Game Boy Advance (GBA):** Extracts information about the title, game code, region, ROM and RAM size, version, etc.
58
- - **Game Boy (GB):** Provides details about the title, cartridge type, ROM and RAM size, and other metadata.
59
- - **Super Nintendo / Super Famicom (SNES/SFC):** Detects the ROM type (HiROM/LoROM), game name, region, code, ROM size, and other specific fields.
97
+ ---
60
98
 
61
- ## API
99
+ ## Error Handling
62
100
 
63
- - `new Roomie(path: string | Buffer)` – Creates an instance and immediately loads the specified ROM file or buffer. Emits the `'loaded'` event when the information is available.
64
- - `await roomie.load(pathOrBuffer: string | Buffer)` Loads or reloads a different ROM file from a file path or a Buffer.
65
- - `roomie.info: RomInfo` Object with the analyzed ROM metadata, including system, size, game code, region, and other specific fields.
66
- - `roomie.rom: Buffer` – Contains the raw bytes of the loaded ROM file.
67
- - `roomie.system: "nds" | "gba" | "gb" | "sfc"` – Detected system based on the file extension and content.
101
+ - `unknown_file`: System extension not supported.
102
+ - `unknown_bytes`: Bytes don't match any known system header.
103
+ - `no_rom_in_zip`: ZIP file loaded but no valid ROM found inside.
68
104
 
69
- If the system cannot be identified, the library throws errors with codes `unknown_file` (when loading from a path) or `unknown_bytes` (when loading from a Buffer).
105
+ ---
70
106
 
71
107
  ## License
72
108
 
package/dist/roomie.d.ts CHANGED
@@ -6,6 +6,7 @@ export interface RomInfo {
6
6
  size: number;
7
7
  hash: {
8
8
  sha1: string;
9
+ crc32: string;
9
10
  };
10
11
  gameCode?: string;
11
12
  region?: string;
@@ -24,6 +25,30 @@ export interface RomInfo {
24
25
  country?: string;
25
26
  version?: string;
26
27
  };
28
+ nes?: {
29
+ version: "1.0" | "2.0";
30
+ mapper: number;
31
+ submapper?: number;
32
+ prgRomSize: number;
33
+ chrRomSize: number;
34
+ prgRamSize?: number;
35
+ prgNvramSize?: number;
36
+ chrRamSize?: number;
37
+ chrNvramSize?: number;
38
+ timing?: string;
39
+ consoleType?: string;
40
+ };
41
+ genesis?: {
42
+ version?: string;
43
+ serial?: string;
44
+ region?: string;
45
+ description?: string;
46
+ };
47
+ sms?: {
48
+ version?: string;
49
+ region?: string;
50
+ serial?: string;
51
+ };
27
52
  }
28
53
  export declare class Roomie extends EventEmitter {
29
54
  private _path;
@@ -35,7 +60,7 @@ export declare class Roomie extends EventEmitter {
35
60
  region?: string;
36
61
  gamecode?: string;
37
62
  cartridge?: Record<string, unknown>;
38
- constructor(path: string);
63
+ constructor(path?: string);
39
64
  private detectSystemFromPath;
40
65
  private readGameCode;
41
66
  private computeRegion;
@@ -50,5 +75,7 @@ export declare class Roomie extends EventEmitter {
50
75
  get system(): SupportedSystem;
51
76
  get path(): string;
52
77
  get rom(): Buffer;
78
+ toJSON(): string;
79
+ toGamelistXML(): string;
53
80
  }
54
81
  export default Roomie;
package/dist/roomie.js CHANGED
@@ -1,13 +1,17 @@
1
+ import path from "node:path";
1
2
  import { EventEmitter } from "node:events";
2
3
  import { createHash } from "node:crypto";
3
4
  import { promises as fs } from "node:fs";
5
+ import AdmZip from "adm-zip";
4
6
  import { regions } from "./tables/regions.js";
5
7
  import { specs } from "./tables/specs.js";
6
8
  import { isHiRomBuffer } from "./systems/snes.js";
9
+ import { crc32 } from "./utils/crc32.js";
7
10
  export class Roomie extends EventEmitter {
8
11
  constructor(path) {
9
12
  super();
10
- this.load(path);
13
+ if (path)
14
+ this.load(path);
11
15
  }
12
16
  detectSystemFromPath(p) {
13
17
  const ext = p.toLowerCase().split(".").pop();
@@ -21,8 +25,21 @@ export class Roomie extends EventEmitter {
21
25
  return "sfc";
22
26
  if (ext === "z64" || ext === "n64")
23
27
  return "n64";
24
- // Default to sfc to keep compatibility with original intent; could be improved.
25
- return "sfc";
28
+ if (ext === "nes")
29
+ return "nes";
30
+ if (ext === "md" || ext === "gen" || ext === "smd")
31
+ return "genesis";
32
+ if (ext === "sms")
33
+ return "sms";
34
+ if (ext === "gg")
35
+ return "gg";
36
+ if (ext === "pce")
37
+ return "pce";
38
+ if (ext === "ws")
39
+ return "ws";
40
+ if (ext === "wsc")
41
+ return "wsc";
42
+ return false;
26
43
  }
27
44
  readGameCode(system) {
28
45
  const b = this._rom;
@@ -90,6 +107,21 @@ export class Roomie extends EventEmitter {
90
107
  return regionMap[regionByte] || "Unknown";
91
108
  }
92
109
  return undefined;
110
+ case "nes":
111
+ if (this._rom.length >= 16) {
112
+ const b = this._rom;
113
+ const isNes2 = (b[7] & 0x0C) === 0x08;
114
+ if (isNes2) {
115
+ const timing = b[12] & 0x03;
116
+ switch (timing) {
117
+ case 0: return "NTSC";
118
+ case 1: return "PAL";
119
+ case 2: return "Multi-region";
120
+ case 3: return "Dendy";
121
+ }
122
+ }
123
+ }
124
+ return undefined;
93
125
  }
94
126
  }
95
127
  computeSfcInfo() {
@@ -166,6 +198,19 @@ export class Roomie extends EventEmitter {
166
198
  return b.subarray(0x20, 0x34).toString("ascii").replace(/\0/g, "").trim();
167
199
  }
168
200
  break;
201
+ case "nes":
202
+ return "NES ROM";
203
+ case "genesis":
204
+ if (b.length >= 0x150) {
205
+ return b.subarray(0x120, 0x150).toString("ascii").trim();
206
+ }
207
+ break;
208
+ case "sms":
209
+ case "gg":
210
+ return "SEGA MASTER/GG ROM";
211
+ case "ws":
212
+ case "wsc":
213
+ return "WONDERSWAN ROM";
169
214
  }
170
215
  }
171
216
  catch { }
@@ -230,18 +275,32 @@ export class Roomie extends EventEmitter {
230
275
  case "gba": {
231
276
  const code = this._gamecode();
232
277
  const region = this._region();
278
+ // GBA Save Type identification (heuristic scanning)
279
+ const saveTypePatterns = ["SRAM_V", "EEPROM_V", "FLASH_V", "FLASH512_V", "FLASH1M_V"];
280
+ let saveType = "Unknown";
281
+ const romStr = b.subarray(0, Math.min(b.length, 0x100000)).toString("ascii"); // Scan first 1MB
282
+ for (const pattern of saveTypePatterns) {
283
+ if (romStr.includes(pattern)) {
284
+ saveType = pattern.split("_")[0];
285
+ break;
286
+ }
287
+ }
233
288
  return {
234
289
  system: "gba",
235
290
  gameCode: code,
236
291
  region,
292
+ saveType,
237
293
  size: b.length,
238
294
  };
239
295
  }
240
296
  case "gb": {
241
297
  const region = this._region();
298
+ const mbcCode = b.length >= 0x148 ? b[0x147].toString(16).padStart(2, "0") : undefined;
299
+ const mbc = mbcCode ? specs.gb_mbc?.[mbcCode] : undefined;
242
300
  return {
243
301
  system: "gb",
244
302
  region,
303
+ mbc,
245
304
  size: b.length,
246
305
  };
247
306
  }
@@ -283,6 +342,107 @@ export class Roomie extends EventEmitter {
283
342
  size: b.length,
284
343
  };
285
344
  }
345
+ case "nes": {
346
+ if (b.length < 16)
347
+ return { system: "nes", size: b.length };
348
+ const isNes2 = (b[7] & 0x0C) === 0x08;
349
+ const mapper = isNes2
350
+ ? (b[6] >> 4) | (b[7] & 0xF0) | ((b[8] & 0x0F) << 8)
351
+ : (b[6] >> 4) | (b[7] & 0xF0);
352
+ const nesInfo = {
353
+ version: isNes2 ? "2.0" : "1.0",
354
+ mapper,
355
+ size: b.length
356
+ };
357
+ const getNesSize = (lsb, msbNibble, baseUnit) => {
358
+ if (msbNibble === 0x0F) {
359
+ const multiplier = (lsb & 0x03) * 2 + 1;
360
+ const exponent = (lsb >> 2);
361
+ return Math.pow(2, exponent) * multiplier;
362
+ }
363
+ return ((msbNibble << 8) | lsb) * baseUnit;
364
+ };
365
+ if (isNes2) {
366
+ nesInfo.submapper = b[8] >> 4;
367
+ nesInfo.prgRomSize = getNesSize(b[4], b[9] & 0x0F, 16384);
368
+ nesInfo.chrRomSize = getNesSize(b[5], (b[9] >> 4) & 0x0F, 8192);
369
+ const prgRamShift = b[10] & 0x0F;
370
+ const prgNvramShift = (b[10] >> 4) & 0x0F;
371
+ if (prgRamShift > 0)
372
+ nesInfo.prgRamSize = 64 << prgRamShift;
373
+ if (prgNvramShift > 0)
374
+ nesInfo.prgNvramSize = 64 << prgNvramShift;
375
+ const chrRamShift = b[11] & 0x0F;
376
+ const chrNvramShift = (b[11] >> 4) & 0x0F;
377
+ if (chrRamShift > 0)
378
+ nesInfo.chrRamSize = 64 << chrRamShift;
379
+ if (chrNvramShift > 0)
380
+ nesInfo.chrNvramSize = 64 << chrNvramShift;
381
+ const timingCodes = ["NTSC", "PAL", "Multi-region", "Dendy"];
382
+ nesInfo.timing = timingCodes[b[12] & 0x03];
383
+ const consoleTypes = ["NES/Famicom", "Vs. System", "PlayChoice-10", "Extended"];
384
+ nesInfo.consoleType = consoleTypes[b[7] & 0x03];
385
+ nesInfo.mapperName = specs.nes?.mappers?.[mapper.toString()];
386
+ }
387
+ else {
388
+ nesInfo.prgRomSize = b[4] * 16384;
389
+ nesInfo.chrRomSize = b[5] * 8192;
390
+ nesInfo.mapperName = specs.nes?.mappers?.[mapper.toString()];
391
+ }
392
+ return {
393
+ system: "nes",
394
+ ...nesInfo
395
+ };
396
+ }
397
+ case "genesis": {
398
+ if (b.length < 0x200)
399
+ return { system: "genesis", size: b.length };
400
+ const region = b.subarray(0x1F0, 0x200).toString("ascii").trim();
401
+ const serial = b.subarray(0x180, 0x18E).toString("ascii").trim();
402
+ const overseasName = b.subarray(0x150, 0x180).toString("ascii").trim();
403
+ return {
404
+ system: "genesis",
405
+ serial,
406
+ region,
407
+ overseasName,
408
+ size: b.length
409
+ };
410
+ }
411
+ case "sms":
412
+ case "gg": {
413
+ const off = b.length >= 0x8000 ? 0x7FF0 : (b.length >= 0x4000 ? 0x3FF0 : 0x1FF0);
414
+ if (b.length < off + 16)
415
+ return { system: this._system, size: b.length };
416
+ const product = b.subarray(off + 12, off + 14).toString("hex");
417
+ const regionByte = b[off + 15] >> 4;
418
+ const region = regionByte >= 4 ? "Export" : "Japan";
419
+ return {
420
+ system: this._system,
421
+ product,
422
+ region,
423
+ size: b.length
424
+ };
425
+ }
426
+ case "ws":
427
+ case "wsc": {
428
+ if (b.length < 10)
429
+ return { system: this._system, size: b.length };
430
+ const off = b.length - 10;
431
+ const publisher = b[off];
432
+ const model = b[off + 1] === 0 ? "WS" : "WSC";
433
+ const gameId = b[off + 2];
434
+ const version = b[off + 3];
435
+ const region = b[off + 4] === 0 ? "Japan" : "Export";
436
+ return {
437
+ system: this._system,
438
+ publisher,
439
+ model,
440
+ gameId,
441
+ version,
442
+ region,
443
+ size: b.length
444
+ };
445
+ }
286
446
  default:
287
447
  return undefined;
288
448
  }
@@ -291,78 +451,154 @@ export class Roomie extends EventEmitter {
291
451
  return this.computeRegion(this._system, this._gamecode());
292
452
  }
293
453
  async load(pathOrBuffer) {
454
+ let b;
294
455
  if (typeof pathOrBuffer === "string") {
295
456
  this._path = pathOrBuffer;
296
- this._rom = await fs.readFile(pathOrBuffer);
297
- this._system = this.detectSystemFromPath(pathOrBuffer);
298
- if (!this._system) {
299
- throw new Error("unknown_file");
457
+ const fileBuffer = await fs.readFile(pathOrBuffer);
458
+ // Check if it's a ZIP by extension or magic
459
+ if (pathOrBuffer.toLowerCase().endsWith(".zip") || (fileBuffer.length > 4 && fileBuffer.readUInt32BE(0) === 0x504B0304)) {
460
+ const zip = new AdmZip(fileBuffer);
461
+ const entries = zip.getEntries();
462
+ // Look for the first entry that doesn't look like junk
463
+ const romEntry = entries.find((e) => !e.isDirectory && !e.entryName.match(/\.(txt|jpg|png|xml|db|url)$|^\./i));
464
+ if (!romEntry)
465
+ throw new Error("no_rom_in_zip");
466
+ b = zip.readFile(romEntry);
300
467
  }
468
+ else {
469
+ b = fileBuffer;
470
+ }
471
+ this._rom = b;
472
+ const detected = this.detectSystemFromPath(pathOrBuffer);
473
+ this._system = detected || "sfc"; // Fallback to be handled by buffer check if needed
301
474
  }
302
475
  else {
303
- this._rom = pathOrBuffer;
304
476
  this._path = "in-memory";
305
- const b = this._rom;
306
- let detected = undefined;
307
- // Check NDS: game code at 0x0C-0x10 ASCII uppercase letters/digits
308
- if (b.length >= 0x10) {
309
- const code = b.subarray(0x0C, 0x10).toString("ascii");
310
- if (/^[A-Z0-9]{4}$/.test(code)) {
311
- detected = "nds";
312
- }
477
+ // Check if buffer is a ZIP
478
+ if (pathOrBuffer.length > 4 && pathOrBuffer.readUInt32BE(0) === 0x504B0304) {
479
+ const zip = new AdmZip(pathOrBuffer);
480
+ const entries = zip.getEntries();
481
+ const romEntry = entries.find((e) => !e.isDirectory && !e.entryName.match(/\.(txt|jpg|png|xml|db|url)$|^\./i));
482
+ if (!romEntry)
483
+ throw new Error("no_rom_in_zip");
484
+ b = zip.readFile(romEntry);
313
485
  }
314
- // Check GBA: game code at 0xAC-0xB0 ASCII uppercase letters/digits
315
- if (!detected && b.length >= 0xB0) {
316
- const code = b.subarray(0xAC, 0xB0).toString("ascii");
317
- if (/^[A-Z0-9]{4}$/.test(code)) {
318
- detected = "gba";
319
- }
486
+ else {
487
+ b = pathOrBuffer;
320
488
  }
321
- // Check GB: game code at 0x0134-0x0143 ASCII valid characters
322
- if (!detected && b.length >= 0x0143) {
323
- const code = b.subarray(0x0134, 0x0143).toString("ascii");
324
- if (/^[A-Z0-9]{4,9}$/.test(code)) {
325
- detected = "gb";
326
- }
489
+ this._rom = b;
490
+ }
491
+ const romBuffer = this._rom;
492
+ let detected = undefined;
493
+ // Check system from bytes (more reliable than extension for ZIPs)
494
+ // -------------------------------------------------------------
495
+ // Check NDS: game code at 0x0C-0x10 ASCII uppercase letters/digits
496
+ if (romBuffer.length >= 0x10) {
497
+ const code = romBuffer.subarray(0x0C, 0x10).toString("ascii");
498
+ if (/^[A-Z0-9]{4}$/.test(code)) {
499
+ detected = "nds";
500
+ }
501
+ }
502
+ // Check GBA: game code at 0xAC-0xB0 ASCII uppercase letters/digits
503
+ if (!detected && b.length >= 0xB0) {
504
+ const code = b.subarray(0xAC, 0xB0).toString("ascii");
505
+ if (/^[A-Z0-9]{4}$/.test(code)) {
506
+ detected = "gba";
327
507
  }
328
- // Check N64: ASCII text at 0x20-0x2E
329
- if (!detected && b.length >= 0x2F) {
330
- const code = b.subarray(0x20, 0x2F).toString("ascii");
331
- if (/^[\x20-\x7E]+$/.test(code) && code.trim().length > 0) {
332
- detected = "n64";
508
+ }
509
+ // Check GB: game code at 0x0134-0x0143 ASCII valid characters
510
+ if (!detected && b.length >= 0x0143) {
511
+ const code = b.subarray(0x0134, 0x0143).toString("ascii");
512
+ if (/^[A-Z0-9]{4,9}$/.test(code)) {
513
+ detected = "gb";
514
+ }
515
+ }
516
+ // Check NES: starts with "NES\x1a"
517
+ if (!detected && b.length >= 4) {
518
+ if (b[0] === 0x4E && b[1] === 0x45 && b[2] === 0x53 && b[3] === 0x1A) {
519
+ detected = "nes";
520
+ }
521
+ }
522
+ // Check N64: ASCII text at 0x20-0x2E AND Magic Word at 0x00
523
+ if (!detected && b.length >= 0x2F) {
524
+ const magic = b.readUInt32BE(0);
525
+ // Common N64 magic values (Big Endian, Byte Swapped, Little Endian)
526
+ if (magic === 0x80371240 || magic === 0x37804012 || magic === 0x40123780) {
527
+ detected = "n64";
528
+ }
529
+ }
530
+ // Check SFC: verify checksum or markup before falling back
531
+ if (!detected && b.length >= 0x8000) {
532
+ if (isHiRomBuffer(b)) {
533
+ detected = "sfc";
534
+ }
535
+ else {
536
+ // LoROM check
537
+ const titleOff = 0x7FC0;
538
+ if (b.length > titleOff + 20) {
539
+ const title = b.subarray(titleOff, titleOff + 20).toString('ascii');
540
+ if (/^[\x20-\x7E\s]+$/.test(title) && title.trim().length > 0) {
541
+ detected = "sfc";
542
+ }
333
543
  }
334
544
  }
335
- // Check SFC: use isHiRomBuffer heuristic
336
- if (!detected) {
337
- if (b.length > 0x8000 && (isHiRomBuffer(b) || !isHiRomBuffer(b))) {
338
- detected = "sfc";
545
+ }
546
+ // Check Genesis: "SEGA" at 0x100
547
+ if (!detected && b.length >= 0x104) {
548
+ const magic = b.subarray(0x100, 0x104).toString("ascii");
549
+ if (magic === "SEGA") {
550
+ detected = "genesis";
551
+ }
552
+ }
553
+ // Check SMS/GG: "TMR SEGA" at 0x7FF0, 0x3FF0 or 0x1FF0
554
+ if (!detected) {
555
+ for (const off of [0x7FF0, 0x3FF0, 0x1FF0]) {
556
+ if (b.length >= off + 8 && b.subarray(off, off + 8).toString("ascii") === "TMR SEGA") {
557
+ detected = b.length >= 0x8000 ? "sms" : "gg"; // Heuristic
558
+ break;
339
559
  }
340
560
  }
341
- if (!detected) {
342
- throw new Error("unknown_bytes");
561
+ }
562
+ // Check WSC: check model byte near end
563
+ if (!detected && b.length >= 0x8000) {
564
+ const off = b.length - 10;
565
+ if (b[off + 1] === 0 || b[off + 1] === 1) { // 0=WS, 1=WSC
566
+ // WSC check is a bit weak without developer ID check, but let's use it as heuristic
567
+ // maybe only if extension also matches?
343
568
  }
344
- this._system = detected;
345
569
  }
570
+ if (!detected) {
571
+ throw new Error("unknown_bytes");
572
+ }
573
+ this._system = detected;
346
574
  const sha1 = createHash("sha1").update(this._rom).digest("hex");
575
+ const crc = crc32(this._rom);
347
576
  const gameCode = this.readGameCode(this._system);
348
577
  const info = {
349
578
  path: this._path,
350
579
  system: this._system,
351
580
  size: this._rom.length,
352
- hash: { sha1 },
581
+ hash: { sha1, crc32: crc },
353
582
  gameCode,
354
583
  region: this.computeRegion(this._system, gameCode),
355
584
  };
356
585
  if (this._system === "sfc") {
357
586
  info.sfc = this.computeSfcInfo();
358
587
  }
359
- if (this._system === "n64") {
588
+ else if (this._system === "n64") {
360
589
  info.n64 = {
361
590
  name: this._name(),
362
591
  country: this._region(),
363
592
  version: this._rom.length > 0x3F ? this._rom[0x3F].toString() : undefined,
364
593
  };
365
594
  }
595
+ else {
596
+ // Generic assignment for new systems (nes, genesis, sms, gg, etc)
597
+ const cart = this._cartridge();
598
+ if (cart) {
599
+ info[this._system] = cart;
600
+ }
601
+ }
366
602
  this._info = info;
367
603
  this.name = this._name();
368
604
  this.gameid = this._gameid();
@@ -375,5 +611,32 @@ export class Roomie extends EventEmitter {
375
611
  get system() { return this._system; }
376
612
  get path() { return this._path; }
377
613
  get rom() { return this._rom; }
614
+ toJSON() {
615
+ return JSON.stringify(this._info, null, 2);
616
+ }
617
+ toGamelistXML() {
618
+ const info = this._info;
619
+ const name = this.name || path.basename(this._path);
620
+ const cart = this.cartridge || {};
621
+ let hardware = "None";
622
+ if (cart.mapperName)
623
+ hardware = `Mapper ${cart.mapper} (${cart.mapperName})`;
624
+ else if (cart.mbc)
625
+ hardware = `MBC (${cart.mbc})`;
626
+ else if (cart.rom?.type)
627
+ hardware = `Type (${cart.rom.type})`;
628
+ else if (cart.saveType)
629
+ hardware = `Save (${cart.saveType})`;
630
+ return `<?xml version="1.0"?>
631
+ <gameList>
632
+ <game>
633
+ <path>${this._path}</path>
634
+ <name>${name}</name>
635
+ <desc>System: ${info.system}, Hardware: ${hardware}</desc>
636
+ <hash>${info.hash.sha1}</hash>
637
+ <crc32>${info.hash.crc32}</crc32>
638
+ </game>
639
+ </gameList>`;
640
+ }
378
641
  }
379
642
  export default Roomie;
@@ -217,5 +217,56 @@ export declare const specs: {
217
217
  };
218
218
  };
219
219
  };
220
+ readonly nes: {
221
+ readonly mappers: {
222
+ readonly "0": "NROM";
223
+ readonly "1": "MMC1";
224
+ readonly "2": "UxROM";
225
+ readonly "3": "CNROM";
226
+ readonly "4": "MMC3";
227
+ readonly "5": "MMC5";
228
+ readonly "7": "AxROM";
229
+ readonly "9": "MMC2";
230
+ readonly "10": "MMC4";
231
+ readonly "11": "Color Dreams";
232
+ readonly "13": "CPROM";
233
+ readonly "15": "100-in-1 Contra Function 16";
234
+ readonly "16": "Bandai FCG-1/2 / LZ93D50";
235
+ readonly "18": "Jaleco SS88006";
236
+ readonly "19": "Namco 163";
237
+ readonly "21": "VRC4a/4c";
238
+ readonly "22": "VRC2a";
239
+ };
240
+ };
241
+ readonly gb_mbc: {
242
+ readonly "00": "ROM ONLY";
243
+ readonly "01": "MBC1";
244
+ readonly "02": "MBC1+RAM";
245
+ readonly "03": "MBC1+RAM+BATTERY";
246
+ readonly "05": "MBC2";
247
+ readonly "06": "MBC2+BATTERY";
248
+ readonly "08": "ROM+RAM";
249
+ readonly "09": "ROM+RAM+BATTERY";
250
+ readonly "0b": "MMM01";
251
+ readonly "0c": "MMM01+RAM";
252
+ readonly "0d": "MMM01+RAM+BATTERY";
253
+ readonly "0f": "MBC3+TIMER+BATTERY";
254
+ readonly "10": "MBC3+TIMER+RAM+BATTERY";
255
+ readonly "11": "MBC3";
256
+ readonly "12": "MBC3+RAM";
257
+ readonly "13": "MBC3+RAM+BATTERY";
258
+ readonly "19": "MBC5";
259
+ readonly "1a": "MBC5+RAM";
260
+ readonly "1b": "MBC5+RAM+BATTERY";
261
+ readonly "1c": "MBC5+RUMBLE";
262
+ readonly "1d": "MBC5+RUMBLE+RAM";
263
+ readonly "1e": "MBC5+RUMBLE+RAM+BATTERY";
264
+ readonly "20": "MBC6";
265
+ readonly "22": "MBC7+SENSOR+RUMBLE+RAM+BATTERY";
266
+ readonly fc: "POCKET CAMERA";
267
+ readonly fd: "BANDAI TAMA5";
268
+ readonly fe: "HuC3";
269
+ readonly ff: "HuC1+RAM+BATTERY";
270
+ };
220
271
  };
221
272
  export type SupportedSystemsForSpecs = keyof typeof specs;
@@ -57,5 +57,56 @@ export const specs = {
57
57
  "31": { type: "HiROM", speed: "3.58MHz" },
58
58
  "32": { type: "ExHiROM", speed: "3.58MHz" },
59
59
  }
60
+ },
61
+ nes: {
62
+ mappers: {
63
+ "0": "NROM",
64
+ "1": "MMC1",
65
+ "2": "UxROM",
66
+ "3": "CNROM",
67
+ "4": "MMC3",
68
+ "5": "MMC5",
69
+ "7": "AxROM",
70
+ "9": "MMC2",
71
+ "10": "MMC4",
72
+ "11": "Color Dreams",
73
+ "13": "CPROM",
74
+ "15": "100-in-1 Contra Function 16",
75
+ "16": "Bandai FCG-1/2 / LZ93D50",
76
+ "18": "Jaleco SS88006",
77
+ "19": "Namco 163",
78
+ "21": "VRC4a/4c",
79
+ "22": "VRC2a",
80
+ }
81
+ },
82
+ gb_mbc: {
83
+ "00": "ROM ONLY",
84
+ "01": "MBC1",
85
+ "02": "MBC1+RAM",
86
+ "03": "MBC1+RAM+BATTERY",
87
+ "05": "MBC2",
88
+ "06": "MBC2+BATTERY",
89
+ "08": "ROM+RAM",
90
+ "09": "ROM+RAM+BATTERY",
91
+ "0b": "MMM01",
92
+ "0c": "MMM01+RAM",
93
+ "0d": "MMM01+RAM+BATTERY",
94
+ "0f": "MBC3+TIMER+BATTERY",
95
+ "10": "MBC3+TIMER+RAM+BATTERY",
96
+ "11": "MBC3",
97
+ "12": "MBC3+RAM",
98
+ "13": "MBC3+RAM+BATTERY",
99
+ "19": "MBC5",
100
+ "1a": "MBC5+RAM",
101
+ "1b": "MBC5+RAM+BATTERY",
102
+ "1c": "MBC5+RUMBLE",
103
+ "1d": "MBC5+RUMBLE+RAM",
104
+ "1e": "MBC5+RUMBLE+RAM+BATTERY",
105
+ "20": "MBC6",
106
+ "22": "MBC7+SENSOR+RUMBLE+RAM+BATTERY",
107
+ "fc": "POCKET CAMERA",
108
+ "fd": "BANDAI TAMA5",
109
+ "fe": "HuC3",
110
+ "ff": "HuC1+RAM+BATTERY"
60
111
  }
61
112
  };
package/dist/types.d.ts CHANGED
@@ -1 +1 @@
1
- export type SupportedSystem = "nds" | "gba" | "gb" | "sfc" | "n64";
1
+ export type SupportedSystem = "nds" | "gba" | "gb" | "sfc" | "n64" | "nes" | "genesis" | "sms" | "gg" | "pce" | "ws" | "wsc";
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Standard CRC32 implementation for Node.js Buffers/TypedArrays.
3
+ */
4
+ export declare function crc32(buffer: Buffer | Uint8Array): string;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Standard CRC32 implementation for Node.js Buffers/TypedArrays.
3
+ */
4
+ export function crc32(buffer) {
5
+ const table = new Int32Array(256);
6
+ for (let i = 0; i < 256; i++) {
7
+ let c = i;
8
+ for (let j = 0; j < 8; j++) {
9
+ c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
10
+ }
11
+ table[i] = c;
12
+ }
13
+ let crc = -1;
14
+ for (let i = 0; i < buffer.length; i++) {
15
+ crc = (crc >>> 8) ^ table[(crc ^ buffer[i]) & 0xFF];
16
+ }
17
+ return ((crc ^ -1) >>> 0).toString(16).padStart(8, '0').toLowerCase();
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roomie",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "ROM metadata helper",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,6 +14,9 @@
14
14
  "default": "./dist/index.mjs"
15
15
  }
16
16
  },
17
+ "dependencies": {
18
+ "adm-zip": "^0.5.10"
19
+ },
17
20
  "engines": {
18
21
  "node": ">=18"
19
22
  },
@@ -28,6 +31,7 @@
28
31
  "prepare": "npm run clean && npm run build"
29
32
  },
30
33
  "devDependencies": {
34
+ "@types/adm-zip": "^0.5.8",
31
35
  "@types/node": "^20.10.0",
32
36
  "rimraf": "^5.0.0",
33
37
  "typescript": "^5.6.0"