@wasm-gaming/fbneo-wasm 0.1.0 → 0.1.2
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 +78 -1
- package/data/romsets/atari.json +12183 -0
- package/data/romsets/capcom.json +66781 -0
- package/data/romsets/cave.json +4888 -0
- package/data/romsets/channelf.json +463 -0
- package/data/romsets/coleco.json +5921 -0
- package/data/romsets/cps3.json +1777 -0
- package/data/romsets/dataeast.json +22415 -0
- package/data/romsets/galaxian.json +14107 -0
- package/data/romsets/index.json +116 -0
- package/data/romsets/irem.json +15032 -0
- package/data/romsets/konami.json +24780 -0
- package/data/romsets/megadrive.json +26352 -0
- package/data/romsets/midway.json +13213 -0
- package/data/romsets/msx.json +16287 -0
- package/data/romsets/neogeo.json +39274 -0
- package/data/romsets/nes.json +28301 -0
- package/data/romsets/pce.json +4397 -0
- package/data/romsets/pgm.json +8860 -0
- package/data/romsets/pgm2.json +397 -0
- package/data/romsets/pre90s.json +143783 -0
- package/data/romsets/psikyo.json +2205 -0
- package/data/romsets/pst90s.json +84614 -0
- package/data/romsets/sega.json +54871 -0
- package/data/romsets/sg1000.json +2300 -0
- package/data/romsets/sms.json +13532 -0
- package/data/romsets/snes.json +23630 -0
- package/data/romsets/spectrum.json +30187 -0
- package/data/romsets/taito.json +40373 -0
- package/data/romsets/toaplan.json +8177 -0
- package/dist/fbneo/neo.d.ts +83 -0
- package/dist/fbneo/neo.d.ts.map +1 -0
- package/dist/fbneo/neo.js +357 -0
- package/dist/fbneo/neo.js.map +1 -0
- package/dist/fbneo/romset.d.ts +0 -0
- package/dist/fbneo/romset.d.ts.map +1 -0
- package/dist/fbneo/romset.js +0 -0
- package/dist/fbneo/romset.js.map +1 -0
- package/package.json +17 -1
- package/src/neo.ts +463 -0
- package/src/romset.ts +0 -0
- package/dist/fbneo/fbneo.js +0 -2
- package/dist/fbneo/fbneo.wasm +0 -0
package/src/neo.ts
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
// Opt-in helper: turn a Neo Geo `.neo` file (the NeoSD / Terraonion flash-cart
|
|
2
|
+
// image) into the FBNeo ROM-set zip the core can actually boot — in the browser,
|
|
3
|
+
// with no dependencies.
|
|
4
|
+
//
|
|
5
|
+
// FBNeo — like MAME — only reads `<driver>.zip` / `<driver>.7z` holding the
|
|
6
|
+
// individual *chip* dumps (p1, s1, c1..cN, m1, v1..vN). A `.neo` is the opposite
|
|
7
|
+
// packaging: a 4096-byte header followed by each region as one contiguous blob,
|
|
8
|
+
// with the C (sprite) region byte-interleaved the way the cartridge wires it. So
|
|
9
|
+
// a `.neo` can never be handed to the engine directly — but every byte FBNeo
|
|
10
|
+
// wants is in there, and this module rebuilds the chip split:
|
|
11
|
+
//
|
|
12
|
+
// header: "NEO1" | P size | S size | M size | V1 size | V2 size | C size | ...
|
|
13
|
+
// data: P | S | M | V1 | V2 | C (C = even bytes -> c1, odd -> c2)
|
|
14
|
+
//
|
|
15
|
+
// The chip layout (how many chips per region and how big each one is) comes from
|
|
16
|
+
// the same `data/romsets/neogeo.json` dataset the `romset` helper uses for
|
|
17
|
+
// content identity: for systems that need it, `gen-romset-index.mjs` records each
|
|
18
|
+
// ROM's size (`s`) and region (`r`) alongside its CRC-32. The driver is
|
|
19
|
+
// identified by matching the `.neo` header's region sizes against those layouts,
|
|
20
|
+
// and every carved chip is CRC-32 verified before a zip is written — if a set
|
|
21
|
+
// needs a layout this simple carve cannot reproduce (some SMA-protected games
|
|
22
|
+
// store their program ROM inside the custom chip), this throws rather than hand
|
|
23
|
+
// the engine a silently broken zip.
|
|
24
|
+
//
|
|
25
|
+
// @example
|
|
26
|
+
// const neogeo = await fetch('/romsets/neogeo.json').then((r) => r.json());
|
|
27
|
+
// if (isNeoFile(bytes)) {
|
|
28
|
+
// const { driver, zip } = neoToRomset(bytes, neogeo);
|
|
29
|
+
// await engine.load({ ...config, assets: { ...assets, rom: zip }, options: { driver } });
|
|
30
|
+
// }
|
|
31
|
+
|
|
32
|
+
import type { RomsetGame, RomsetSystem } from './romset.js';
|
|
33
|
+
|
|
34
|
+
/** A Neo Geo cartridge region, in the order a `.neo` file stores them. */
|
|
35
|
+
export type NeoRegion = 'P' | 'S' | 'M' | 'V1' | 'V2' | 'C';
|
|
36
|
+
|
|
37
|
+
/** The regions in `.neo` file order — the order the blobs follow the header. */
|
|
38
|
+
export const NEO_REGIONS: readonly NeoRegion[] = ['P', 'S', 'M', 'V1', 'V2', 'C'];
|
|
39
|
+
|
|
40
|
+
/** Header fields of a `.neo` file. */
|
|
41
|
+
export interface NeoInfo {
|
|
42
|
+
/** Cartridge title from the header's `Name[33]` field. */
|
|
43
|
+
name: string;
|
|
44
|
+
/** Manufacturer from the header's `Manu[17]` field. */
|
|
45
|
+
manufacturer: string;
|
|
46
|
+
year: number;
|
|
47
|
+
/** Bytes each region occupies in the file. */
|
|
48
|
+
sizes: Record<NeoRegion, number>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A parsed `.neo` file: its header plus a view over each region's bytes. */
|
|
52
|
+
export interface NeoFile extends NeoInfo {
|
|
53
|
+
/** Views into the source buffer — no copying. */
|
|
54
|
+
regions: Record<NeoRegion, Uint8Array>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One chip carved out of a `.neo`, ready to become a zip entry. */
|
|
58
|
+
export interface NeoChip {
|
|
59
|
+
/** Internal ROM name FBNeo expects (e.g. `201-c1.c1`). */
|
|
60
|
+
name: string;
|
|
61
|
+
/** Lowercase 8-hex CRC-32 of `data`, verified against the driver table. */
|
|
62
|
+
crc: string;
|
|
63
|
+
region: NeoRegion;
|
|
64
|
+
data: Uint8Array;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The result of a successful conversion. */
|
|
68
|
+
export interface NeoRomset {
|
|
69
|
+
/** FBNeo driver short name — pass as `options.driver`. */
|
|
70
|
+
driver: string;
|
|
71
|
+
system: string;
|
|
72
|
+
/** Clone parent's driver name, or `null` for a parent set. */
|
|
73
|
+
parent: string | null;
|
|
74
|
+
info: NeoInfo;
|
|
75
|
+
chips: NeoChip[];
|
|
76
|
+
/**
|
|
77
|
+
* The rebuilt ROM set as a *stored* (uncompressed) zip — feed as `assets.rom`.
|
|
78
|
+
* Deflating would only be undone by FBNeo on the next line, so the bytes are
|
|
79
|
+
* stored: the conversion stays synchronous and costs no extra CPU.
|
|
80
|
+
*/
|
|
81
|
+
zip: Uint8Array;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface NeoToRomsetOptions {
|
|
85
|
+
/** Force a driver instead of matching the header's region sizes against every set. */
|
|
86
|
+
driver?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const NEO_HEADER_SIZE = 4096;
|
|
90
|
+
const NAME_OFFSET = 0x2c;
|
|
91
|
+
const NAME_LENGTH = 33;
|
|
92
|
+
const MANUFACTURER_OFFSET = 0x4d;
|
|
93
|
+
const MANUFACTURER_LENGTH = 17;
|
|
94
|
+
|
|
95
|
+
/** Cheap sniff: does this look like a `.neo` flash-cart image? */
|
|
96
|
+
export function isNeoFile(bytes: Uint8Array): boolean {
|
|
97
|
+
return (
|
|
98
|
+
bytes.length > NEO_HEADER_SIZE &&
|
|
99
|
+
bytes[0] === 0x4e /* N */ &&
|
|
100
|
+
bytes[1] === 0x45 /* E */ &&
|
|
101
|
+
bytes[2] === 0x4f /* O */
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Read a `.neo` file's header and slice out each region.
|
|
107
|
+
*
|
|
108
|
+
* @throws if the magic is missing or the file is shorter than its header describes.
|
|
109
|
+
*/
|
|
110
|
+
export function parseNeo(bytes: Uint8Array): NeoFile {
|
|
111
|
+
if (!isNeoFile(bytes)) throw new Error('neo: not a .neo file (missing NEO magic)');
|
|
112
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
113
|
+
|
|
114
|
+
// Region sizes are six consecutive LE uint32s, but the header stores them in
|
|
115
|
+
// P S M V1 V2 C order while the C blob is written last — same order here.
|
|
116
|
+
const sizes = {
|
|
117
|
+
P: view.getUint32(4, true),
|
|
118
|
+
S: view.getUint32(8, true),
|
|
119
|
+
M: view.getUint32(12, true),
|
|
120
|
+
V1: view.getUint32(16, true),
|
|
121
|
+
V2: view.getUint32(20, true),
|
|
122
|
+
C: view.getUint32(24, true),
|
|
123
|
+
} satisfies Record<NeoRegion, number>;
|
|
124
|
+
|
|
125
|
+
const regions = {} as Record<NeoRegion, Uint8Array>;
|
|
126
|
+
let offset = NEO_HEADER_SIZE;
|
|
127
|
+
for (const region of NEO_REGIONS) {
|
|
128
|
+
const end = offset + sizes[region];
|
|
129
|
+
if (end > bytes.length) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`neo: truncated file — header describes ${end} bytes through region ${region}, ` +
|
|
132
|
+
`file is ${bytes.length}`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
regions[region] = bytes.subarray(offset, end);
|
|
136
|
+
offset = end;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
name: cString(bytes, NAME_OFFSET, NAME_LENGTH),
|
|
141
|
+
manufacturer: cString(bytes, MANUFACTURER_OFFSET, MANUFACTURER_LENGTH),
|
|
142
|
+
year: view.getUint32(28, true),
|
|
143
|
+
sizes,
|
|
144
|
+
regions,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Read a fixed-width NUL-padded latin-1 string field. */
|
|
149
|
+
function cString(bytes: Uint8Array, start: number, length: number): string {
|
|
150
|
+
let end = start;
|
|
151
|
+
const limit = Math.min(start + length, bytes.length);
|
|
152
|
+
while (end < limit && bytes[end] !== 0) end++;
|
|
153
|
+
let out = '';
|
|
154
|
+
for (let i = start; i < end; i++) out += String.fromCharCode(bytes[i]!);
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Rebuild an FBNeo ROM set from a `.neo` file.
|
|
160
|
+
*
|
|
161
|
+
* @param bytes The raw `.neo` bytes.
|
|
162
|
+
* @param datasets One or more romset datasets carrying chip layouts (`s`/`r` per
|
|
163
|
+
* ROM) — in practice `data/romsets/neogeo.json`.
|
|
164
|
+
* @throws if no driver's chip layout matches the header's region sizes, or if
|
|
165
|
+
* every size-matched candidate fails CRC-32 verification.
|
|
166
|
+
*/
|
|
167
|
+
export function neoToRomset(
|
|
168
|
+
bytes: Uint8Array,
|
|
169
|
+
datasets: RomsetSystem | readonly RomsetSystem[],
|
|
170
|
+
options: NeoToRomsetOptions = {},
|
|
171
|
+
): NeoRomset {
|
|
172
|
+
const neo = parseNeo(bytes);
|
|
173
|
+
const systems: readonly RomsetSystem[] = Array.isArray(datasets)
|
|
174
|
+
? (datasets as readonly RomsetSystem[])
|
|
175
|
+
: [datasets as RomsetSystem];
|
|
176
|
+
|
|
177
|
+
// Candidates: drivers whose chip layout adds up to exactly this .neo's regions.
|
|
178
|
+
// A size match alone can be ambiguous between a set and its clones, so this is
|
|
179
|
+
// only a filter — the CRC check below decides. Parents are tried first so the
|
|
180
|
+
// canonical boot name wins when a clone is byte-identical.
|
|
181
|
+
const candidates: { system: string; driver: string; game: RomsetGame }[] = [];
|
|
182
|
+
for (const system of systems) {
|
|
183
|
+
for (const [driver, game] of Object.entries(system.games)) {
|
|
184
|
+
if (options.driver ? driver !== options.driver : !layoutMatchesSizes(game, neo.sizes)) continue;
|
|
185
|
+
candidates.push({ system: system.system, driver, game });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
candidates.sort(
|
|
189
|
+
(a, b) =>
|
|
190
|
+
Number(a.game.parent === null) - Number(b.game.parent === null) ||
|
|
191
|
+
a.driver.localeCompare(b.driver),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
if (!candidates.length) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
options.driver
|
|
197
|
+
? `neo: driver "${options.driver}" is not in the supplied dataset(s)`
|
|
198
|
+
: `neo: no driver matches this file's region sizes ` +
|
|
199
|
+
`(${NEO_REGIONS.map((r) => `${r}=0x${neo.sizes[r].toString(16)}`).join(' ')}) — ` +
|
|
200
|
+
'not a Neo Geo set this FBNeo build knows',
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const failures: string[] = [];
|
|
205
|
+
for (const { system, driver, game } of candidates) {
|
|
206
|
+
let carved: CarvedChip[];
|
|
207
|
+
try {
|
|
208
|
+
carved = carve(game, neo.regions);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
failures.push(`${driver}: ${(err as Error).message}`);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
// Every chip must hash to the CRC-32 the driver table declares. This is what
|
|
214
|
+
// separates a real match from a set that merely happens to share region sizes,
|
|
215
|
+
// and what catches layouts this straight carve cannot reproduce.
|
|
216
|
+
const chips: NeoChip[] = carved.map((chip) => ({
|
|
217
|
+
name: chip.name,
|
|
218
|
+
region: chip.region,
|
|
219
|
+
data: chip.data,
|
|
220
|
+
crc: crc32Hex(chip.data),
|
|
221
|
+
}));
|
|
222
|
+
const bad = chips.filter((chip, i) => chip.crc !== carved[i]!.expected);
|
|
223
|
+
if (bad.length) {
|
|
224
|
+
failures.push(
|
|
225
|
+
`${driver}: ${bad.length}/${chips.length} chips CRC-mismatched ` +
|
|
226
|
+
`(${bad.slice(0, 4).map((c) => c.name).join(', ')}${bad.length > 4 ? ', …' : ''})`,
|
|
227
|
+
);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
driver,
|
|
232
|
+
system,
|
|
233
|
+
parent: game.parent,
|
|
234
|
+
info: { name: neo.name, manufacturer: neo.manufacturer, year: neo.year, sizes: neo.sizes },
|
|
235
|
+
chips,
|
|
236
|
+
zip: zipStore(chips),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
throw new Error(
|
|
241
|
+
`neo: matched ${candidates.length} driver(s) by size but none by content:\n ${failures.join('\n ')}`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The chips of a game's layout, in table order. Sets without layout data yield none. */
|
|
246
|
+
function layoutChips(game: RomsetGame): { n: string; c: string; s: number; r: NeoRegion }[] {
|
|
247
|
+
const out: { n: string; c: string; s: number; r: NeoRegion }[] = [];
|
|
248
|
+
for (const rom of game.roms) {
|
|
249
|
+
// Rows outside the six cartridge regions (BIOS, the SMA custom chip, padding)
|
|
250
|
+
// carry no `r` and occupy no bytes in a .neo — skip without moving any cursor.
|
|
251
|
+
if (rom.r === undefined || rom.s === undefined) continue;
|
|
252
|
+
if (!NEO_REGIONS.includes(rom.r as NeoRegion)) continue;
|
|
253
|
+
out.push({ n: rom.n, c: rom.c, s: rom.s, r: rom.r as NeoRegion });
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function layoutMatchesSizes(game: RomsetGame, sizes: Record<NeoRegion, number>): boolean {
|
|
259
|
+
const chips = layoutChips(game);
|
|
260
|
+
if (!chips.length) return false;
|
|
261
|
+
const sums: Record<NeoRegion, number> = { P: 0, S: 0, M: 0, V1: 0, V2: 0, C: 0 };
|
|
262
|
+
for (const chip of chips) sums[chip.r] += chip.s;
|
|
263
|
+
return NEO_REGIONS.every((r) => sums[r] === sizes[r]);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** A chip sliced out of the blobs, carrying the CRC-32 its driver table expects. */
|
|
267
|
+
interface CarvedChip {
|
|
268
|
+
name: string;
|
|
269
|
+
region: NeoRegion;
|
|
270
|
+
data: Uint8Array;
|
|
271
|
+
/** CRC-32 from the driver table — what `data` has to hash to. */
|
|
272
|
+
expected: string;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Carve a driver's chip files out of the `.neo` regions.
|
|
277
|
+
*
|
|
278
|
+
* C chips come in even/odd pairs sharing one interleaved slice of the C region;
|
|
279
|
+
* every other region is a straight sequential split.
|
|
280
|
+
*/
|
|
281
|
+
function carve(game: RomsetGame, regions: Record<NeoRegion, Uint8Array>): CarvedChip[] {
|
|
282
|
+
const layout = layoutChips(game);
|
|
283
|
+
const cursor: Record<NeoRegion, number> = { P: 0, S: 0, M: 0, V1: 0, V2: 0, C: 0 };
|
|
284
|
+
const out: CarvedChip[] = [];
|
|
285
|
+
|
|
286
|
+
// Size matching normally guarantees the cursors land exactly on each region's
|
|
287
|
+
// end, but a forced `options.driver` can ask for a layout that overruns — and
|
|
288
|
+
// `subarray` would clamp rather than complain, leaving a short chip to fail CRC
|
|
289
|
+
// for a misleading reason.
|
|
290
|
+
const take = (region: NeoRegion, length: number): Uint8Array => {
|
|
291
|
+
const start = cursor[region];
|
|
292
|
+
if (start + length > regions[region].length) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`region ${region} is ${regions[region].length} bytes, layout needs at least ${start + length}`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
cursor[region] = start + length;
|
|
298
|
+
return regions[region].subarray(start, start + length);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
for (let i = 0; i < layout.length; i++) {
|
|
302
|
+
const rom = layout[i]!;
|
|
303
|
+
|
|
304
|
+
if (rom.r !== 'C') {
|
|
305
|
+
const first = cursor[rom.r] === 0;
|
|
306
|
+
let data = take(rom.r, rom.s);
|
|
307
|
+
if (game.swapP && rom.r === 'P' && first) {
|
|
308
|
+
// On these carts the first program ROM has its two halves swapped; FBNeo
|
|
309
|
+
// undoes that after loading (neogeo.cpp, HARDWARE_SNK_SWAPP), so a .neo —
|
|
310
|
+
// which stores the cart's own order — has to be swapped back to match the
|
|
311
|
+
// dump's CRC.
|
|
312
|
+
const half = rom.s >> 1;
|
|
313
|
+
const swapped = new Uint8Array(rom.s);
|
|
314
|
+
swapped.set(data.subarray(half), 0);
|
|
315
|
+
swapped.set(data.subarray(0, half), rom.s - half);
|
|
316
|
+
data = swapped;
|
|
317
|
+
}
|
|
318
|
+
out.push({ name: rom.n, expected: rom.c, region: rom.r, data });
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Interleaved pair: this chip supplies the even bytes, the next one the odd.
|
|
323
|
+
const mate = layout[i + 1];
|
|
324
|
+
if (!mate || mate.r !== 'C' || mate.s !== rom.s) {
|
|
325
|
+
throw new Error(`unpaired C ROM "${rom.n}" — cannot de-interleave sprite data`);
|
|
326
|
+
}
|
|
327
|
+
const slice = take('C', rom.s * 2);
|
|
328
|
+
const even = new Uint8Array(rom.s);
|
|
329
|
+
const odd = new Uint8Array(rom.s);
|
|
330
|
+
for (let b = 0; b < rom.s; b++) {
|
|
331
|
+
even[b] = slice[2 * b]!;
|
|
332
|
+
odd[b] = slice[2 * b + 1]!;
|
|
333
|
+
}
|
|
334
|
+
out.push(
|
|
335
|
+
{ name: rom.n, expected: rom.c, region: 'C', data: even },
|
|
336
|
+
{ name: mate.n, expected: mate.c, region: 'C', data: odd },
|
|
337
|
+
);
|
|
338
|
+
i++;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// CRC-32 and a minimal stored-zip writer
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
let crcTable: Uint32Array | null = null;
|
|
349
|
+
|
|
350
|
+
function getCrcTable(): Uint32Array {
|
|
351
|
+
if (crcTable) return crcTable;
|
|
352
|
+
const table = new Uint32Array(256);
|
|
353
|
+
for (let i = 0; i < 256; i++) {
|
|
354
|
+
let c = i;
|
|
355
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
356
|
+
table[i] = c >>> 0;
|
|
357
|
+
}
|
|
358
|
+
return (crcTable = table);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** CRC-32 (IEEE, the one zip and FBNeo's ROM tables use) of `bytes`. */
|
|
362
|
+
export function crc32(bytes: Uint8Array): number {
|
|
363
|
+
const table = getCrcTable();
|
|
364
|
+
let c = 0xffffffff;
|
|
365
|
+
for (let i = 0; i < bytes.length; i++) c = table[(c ^ bytes[i]!) & 0xff]! ^ (c >>> 8);
|
|
366
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Lowercase 8-hex CRC-32, the form the romset dataset stores. */
|
|
370
|
+
export function crc32Hex(bytes: Uint8Array): string {
|
|
371
|
+
return crc32(bytes).toString(16).padStart(8, '0');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const LFH_SIG = 0x04034b50;
|
|
375
|
+
const CDH_SIG = 0x02014b50;
|
|
376
|
+
const EOCD_SIG = 0x06054b50;
|
|
377
|
+
const DOS_EPOCH_DATE = 0x0021; // 1980-01-01, the earliest date the format can express
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Build a zip with every entry *stored* (method 0).
|
|
381
|
+
*
|
|
382
|
+
* FBNeo decompresses each chip straight into its ROM buffer, so deflating here
|
|
383
|
+
* would just be undone a moment later; storing keeps this synchronous (no
|
|
384
|
+
* `CompressionStream`) and costs one pass over the data.
|
|
385
|
+
*/
|
|
386
|
+
export function zipStore(entries: readonly { name: string; data: Uint8Array }[]): Uint8Array {
|
|
387
|
+
const names = entries.map((e) => latin1(e.name));
|
|
388
|
+
const crcs = entries.map((e) => crc32(e.data));
|
|
389
|
+
|
|
390
|
+
let localSize = 0;
|
|
391
|
+
let centralSize = 0;
|
|
392
|
+
for (let i = 0; i < entries.length; i++) {
|
|
393
|
+
localSize += 30 + names[i]!.length + entries[i]!.data.length;
|
|
394
|
+
centralSize += 46 + names[i]!.length;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const out = new Uint8Array(localSize + centralSize + 22);
|
|
398
|
+
const view = new DataView(out.buffer);
|
|
399
|
+
const offsets: number[] = [];
|
|
400
|
+
let p = 0;
|
|
401
|
+
|
|
402
|
+
for (let i = 0; i < entries.length; i++) {
|
|
403
|
+
const { data } = entries[i]!;
|
|
404
|
+
const name = names[i]!;
|
|
405
|
+
offsets.push(p);
|
|
406
|
+
view.setUint32(p, LFH_SIG, true);
|
|
407
|
+
view.setUint16(p + 4, 10, true); // version needed: 1.0 (stored)
|
|
408
|
+
view.setUint16(p + 6, 0, true); // flags
|
|
409
|
+
view.setUint16(p + 8, 0, true); // method: stored
|
|
410
|
+
view.setUint16(p + 10, 0, true); // dos time
|
|
411
|
+
view.setUint16(p + 12, DOS_EPOCH_DATE, true);
|
|
412
|
+
view.setUint32(p + 14, crcs[i]!, true);
|
|
413
|
+
view.setUint32(p + 18, data.length, true); // compressed size
|
|
414
|
+
view.setUint32(p + 22, data.length, true); // uncompressed size
|
|
415
|
+
view.setUint16(p + 26, name.length, true);
|
|
416
|
+
view.setUint16(p + 28, 0, true); // extra length
|
|
417
|
+
out.set(name, p + 30);
|
|
418
|
+
out.set(data, p + 30 + name.length);
|
|
419
|
+
p += 30 + name.length + data.length;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const centralStart = p;
|
|
423
|
+
for (let i = 0; i < entries.length; i++) {
|
|
424
|
+
const { data } = entries[i]!;
|
|
425
|
+
const name = names[i]!;
|
|
426
|
+
view.setUint32(p, CDH_SIG, true);
|
|
427
|
+
view.setUint16(p + 4, 10, true); // version made by
|
|
428
|
+
view.setUint16(p + 6, 10, true); // version needed
|
|
429
|
+
view.setUint16(p + 8, 0, true); // flags
|
|
430
|
+
view.setUint16(p + 10, 0, true); // method: stored
|
|
431
|
+
view.setUint16(p + 12, 0, true); // dos time
|
|
432
|
+
view.setUint16(p + 14, DOS_EPOCH_DATE, true);
|
|
433
|
+
view.setUint32(p + 16, crcs[i]!, true);
|
|
434
|
+
view.setUint32(p + 20, data.length, true);
|
|
435
|
+
view.setUint32(p + 24, data.length, true);
|
|
436
|
+
view.setUint16(p + 28, name.length, true);
|
|
437
|
+
view.setUint16(p + 30, 0, true); // extra length
|
|
438
|
+
view.setUint16(p + 32, 0, true); // comment length
|
|
439
|
+
view.setUint16(p + 34, 0, true); // disk number
|
|
440
|
+
view.setUint16(p + 36, 0, true); // internal attrs
|
|
441
|
+
view.setUint32(p + 38, 0, true); // external attrs
|
|
442
|
+
view.setUint32(p + 42, offsets[i]!, true);
|
|
443
|
+
out.set(name, p + 46);
|
|
444
|
+
p += 46 + name.length;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
view.setUint32(p, EOCD_SIG, true);
|
|
448
|
+
view.setUint16(p + 4, 0, true); // disk number
|
|
449
|
+
view.setUint16(p + 6, 0, true); // central directory start disk
|
|
450
|
+
view.setUint16(p + 8, entries.length, true);
|
|
451
|
+
view.setUint16(p + 10, entries.length, true);
|
|
452
|
+
view.setUint32(p + 12, centralSize, true);
|
|
453
|
+
view.setUint32(p + 16, centralStart, true);
|
|
454
|
+
view.setUint16(p + 20, 0, true); // comment length
|
|
455
|
+
|
|
456
|
+
return out;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function latin1(text: string): Uint8Array {
|
|
460
|
+
const out = new Uint8Array(text.length);
|
|
461
|
+
for (let i = 0; i < text.length; i++) out[i] = text.charCodeAt(i) & 0xff;
|
|
462
|
+
return out;
|
|
463
|
+
}
|
package/src/romset.ts
ADDED
|
Binary file
|