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 +72 -36
- package/dist/roomie.d.ts +28 -1
- package/dist/roomie.js +305 -42
- package/dist/tables/specs.d.ts +51 -0
- package/dist/tables/specs.js +51 -0
- package/dist/types.d.ts +1 -1
- package/dist/utils/crc32.d.ts +4 -0
- package/dist/utils/crc32.js +18 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -2,71 +2,107 @@
|
|
|
2
2
|
|
|
3
3
|
 
|
|
4
4
|
|
|
5
|
+
---
|
|
5
6
|
|
|
7
|
+
## Introduction
|
|
6
8
|
|
|
7
|
-
|
|
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
|
-
|
|
37
|
+
**roomie** supports both CommonJS (CJS) and ES Modules (ESM).
|
|
18
38
|
|
|
19
|
-
###
|
|
39
|
+
### Basic Example (Async/Await)
|
|
20
40
|
|
|
21
|
-
```
|
|
22
|
-
|
|
41
|
+
```ts
|
|
42
|
+
import Roomie from "roomie";
|
|
23
43
|
|
|
24
|
-
const
|
|
25
|
-
const roomie = new Roomie(romPath);
|
|
44
|
+
const roomie = new Roomie();
|
|
26
45
|
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
63
|
+
---
|
|
33
64
|
|
|
34
|
-
|
|
35
|
-
import Roomie from "roomie";
|
|
65
|
+
## Supported Consoles
|
|
36
66
|
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
console.log("ROM Information:", info);
|
|
42
|
-
});
|
|
80
|
+
---
|
|
43
81
|
|
|
44
|
-
|
|
82
|
+
## API Reference
|
|
45
83
|
|
|
46
|
-
|
|
47
|
-
await roomie.load(romBuffer);
|
|
84
|
+
### Methods
|
|
48
85
|
|
|
49
|
-
|
|
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
|
-
|
|
90
|
+
### Properties
|
|
53
91
|
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
99
|
+
## Error Handling
|
|
62
100
|
|
|
63
|
-
- `
|
|
64
|
-
- `
|
|
65
|
-
- `
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
if (
|
|
299
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
if (
|
|
311
|
-
|
|
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
|
-
|
|
315
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
342
|
-
|
|
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;
|
package/dist/tables/specs.d.ts
CHANGED
|
@@ -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;
|
package/dist/tables/specs.js
CHANGED
|
@@ -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,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
|
+
"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"
|