Skip to content

Commit c81fb78

Browse files
committed
Add a base rom version selector for ROM hacks 🦠.
1 parent 49d70ad commit c81fb78

File tree

8 files changed

+181
-30
lines changed

8 files changed

+181
-30
lines changed

src/components/BaseRomDialog.js

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { Fragment, useState } from 'react';
2+
import {
3+
Dialog,
4+
DialogPanel,
5+
DialogTitle,
6+
Transition,
7+
TransitionChild,
8+
} from '@headlessui/react';
9+
import { QuestionMarkCircleIcon } from '@heroicons/react/24/outline';
10+
import resources from '../lib/resources';
11+
12+
const BASE_ROMS = resources.map(({ metadata: { name } }) => name);
13+
BASE_ROMS.sort();
14+
const defaultValue = 'USA'; // The USA version is the most commonly used base ROM.
15+
16+
const BaseRomDialog = ({ open, setOpen, setBaseRom }) => {
17+
const [baseRom, setInternalBaseRom] = useState(defaultValue);
18+
19+
return (
20+
<Transition
21+
show={open}
22+
as={Fragment}>
23+
<Dialog
24+
className="relative z-10"
25+
onClose={() => {}}>
26+
<TransitionChild
27+
as={Fragment}
28+
enter="ease-out duration-300"
29+
enterFrom="opacity-0"
30+
enterTo="opacity-100"
31+
leave="ease-in duration-200"
32+
leaveFrom="opacity-100"
33+
leaveTo="opacity-0">
34+
<div className="fixed inset-0 bg-slate-100/75 opacity-100 dark:bg-slate-900/75" />
35+
</TransitionChild>
36+
37+
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
38+
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
39+
<TransitionChild
40+
as={Fragment}
41+
enter="ease-out duration-300"
42+
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
43+
enterTo="opacity-100 translate-y-0 sm:scale-100"
44+
leave="ease-in duration-200"
45+
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
46+
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
47+
<DialogPanel className="relative transform overflow-hidden rounded-md bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6">
48+
<div>
49+
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-primary-100">
50+
<QuestionMarkCircleIcon
51+
className="size-6 text-primary-600"
52+
aria-hidden="true"
53+
/>
54+
</div>
55+
<div className="mt-3 sm:mt-5">
56+
<DialogTitle
57+
as="h3"
58+
className="text-center text-base font-semibold leading-6 text-gray-900">
59+
Unrecognised ROM
60+
</DialogTitle>
61+
<div className="my-2">
62+
<p className="text-sm text-gray-500">
63+
The ROM you selected could not be identified. If it's a
64+
ROM hack, select the version used as its base.
65+
</p>
66+
</div>
67+
<BaseRomSelector setBaseRom={setInternalBaseRom} />
68+
</div>
69+
</div>
70+
<div className="mt-5 sm:mt-6">
71+
<button
72+
type="button"
73+
className="inline-flex w-full justify-center rounded-md bg-primary-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-primary-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600"
74+
onClick={() => {
75+
setOpen(false);
76+
setBaseRom(baseRom);
77+
}}>
78+
Select base ROM version
79+
</button>
80+
</div>
81+
</DialogPanel>
82+
</TransitionChild>
83+
</div>
84+
</div>
85+
</Dialog>
86+
</Transition>
87+
);
88+
};
89+
90+
const BaseRomSelector = ({ setBaseRom }) => {
91+
return (
92+
<>
93+
<label
94+
htmlFor="base-rom"
95+
className="block text-sm font-medium leading-6 text-gray-900">
96+
Base ROM version
97+
</label>
98+
<select
99+
id="base-rom"
100+
name="base-rom"
101+
className="mt-2 block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-primary-600 sm:text-sm sm:leading-6"
102+
defaultValue={defaultValue}
103+
onChange={({ target }) => setBaseRom(target.value)}>
104+
{BASE_ROMS.map((baseRom) => (
105+
<option key={baseRom}>{baseRom}</option>
106+
))}
107+
</select>
108+
</>
109+
);
110+
};
111+
112+
export default BaseRomDialog;

src/components/DropZone.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const DropZone = ({ children, isDragActive, isDragReject, errorCode }) => {
2828
errorMsg = 'The Japanese Famicom version is not supported.';
2929
break;
3030
case 'invalid-rom-file':
31-
errorMsg = 'Upload one of the ROMs of Maniac Mansion on NES.';
31+
errorMsg = 'The ROM was not recognised as one of Maniac Mansion on NES.';
3232
break;
3333
case 'reading-file-failed':
3434
errorMsg = 'File reading has failed. Please try again.';

src/components/ErrorMessage.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
const ErrorMessage = () => {
2-
return <h1>Something went wrong.</h1>;
2+
return (
3+
<>
4+
<h1 className="mb-2 whitespace-nowrap text-2xl font-semibold text-slate-700 md:text-3xl dark:text-slate-300">
5+
Something went wrong
6+
</h1>
7+
<p>Open the console for more info.</p>
8+
</>
9+
);
310
};
411

512
export default ErrorMessage;

src/containers/DropZoneContainer.js

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
22
import { useDropzone } from 'react-dropzone';
33
import Main from '../components/Main';
44
import DropZone from '../components/DropZone';
5+
import BaseRomDialog from '../components/BaseRomDialog';
56

67
const DropZoneContainer = ({ onFile }) => {
78
const validator = (file) => {
@@ -27,12 +28,18 @@ const DropZoneContainer = ({ onFile }) => {
2728
setErrorCode('reading-file-failed');
2829
};
2930
reader.onload = async () => {
31+
const { hasNesHeader } = await import('../lib/utils');
3032
const { default: crc32 } = await import('../lib/crc32');
3133
const { isJapaneseVersion, getResFromCrc32 } = await import(
3234
'../lib/getResFromCrc32'
3335
);
3436

3537
let arrayBuffer = reader.result;
38+
39+
if (file.name.endsWith('.nes') || hasNesHeader(arrayBuffer)) {
40+
arrayBuffer = arrayBuffer.slice(16);
41+
}
42+
3643
const dataView = new DataView(arrayBuffer);
3744
const c = crc32(dataView);
3845

@@ -43,16 +50,13 @@ const DropZoneContainer = ({ onFile }) => {
4350

4451
const res = getResFromCrc32(c);
4552

53+
setRom(arrayBuffer);
54+
4655
if (!res) {
47-
setErrorCode('invalid-rom-file');
56+
setBaseRomDialogOpened(true);
4857
return;
4958
}
5059

51-
if (file.name.endsWith('.nes')) {
52-
arrayBuffer = arrayBuffer.slice(16);
53-
}
54-
55-
setRom(arrayBuffer);
5660
setRes(res);
5761
};
5862
reader.readAsArrayBuffer(file);
@@ -61,6 +65,8 @@ const DropZoneContainer = ({ onFile }) => {
6165
};
6266

6367
const [errorCode, setErrorCode] = useState(null);
68+
const [baseRomDialogOpened, setBaseRomDialogOpened] = useState(false);
69+
const [baseRom, setBaseRom] = useState(null);
6470
const [rom, setRom] = useState(null);
6571
const [res, setRes] = useState(null);
6672
const {
@@ -76,6 +82,21 @@ const DropZoneContainer = ({ onFile }) => {
7682
validator,
7783
});
7884

85+
if (baseRom) {
86+
(async () => {
87+
const { getResFromBaseRom } = await import('../lib/getResFromCrc32');
88+
const { default: parseRom } = await import('../lib/parser/parseRom');
89+
const res = getResFromBaseRom(baseRom);
90+
91+
try {
92+
parseRom(rom, res);
93+
setRes(res);
94+
} catch (err) {
95+
setErrorCode('invalid-rom-file');
96+
}
97+
})();
98+
}
99+
79100
if (fileRejections[0] && fileRejections[0]?.errors[0]?.code && !errorCode) {
80101
setErrorCode(fileRejections[0].errors[0].code);
81102
}
@@ -88,6 +109,11 @@ const DropZoneContainer = ({ onFile }) => {
88109

89110
return (
90111
<Main>
112+
<BaseRomDialog
113+
open={baseRomDialogOpened}
114+
setOpen={setBaseRomDialogOpened}
115+
setBaseRom={setBaseRom}
116+
/>
91117
<div
92118
className="h-full w-full p-4"
93119
{...getRootProps()}>

src/lib/cliUtils.js

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { extname } from 'node:path';
22
import { readFile, writeFile } from 'node:fs/promises';
33
import crc32 from './crc32.js';
44
import { isJapaneseVersion, getResFromCrc32 } from './getResFromCrc32.js';
5-
import { hex } from './utils.js';
5+
import { hex, hasNesHeader } from './utils.js';
66

77
const BANK_SIZE = 0x4000;
88
// prettier-ignore
@@ -119,17 +119,6 @@ const stringifyResources = (hash, size, resources, res) => {
119119
return JSON.stringify(data, null, ' ');
120120
};
121121

122-
// Return true if an arrayBuffer has a NES header.
123-
const hasNesHeader = (bin) => {
124-
const view = new DataView(bin);
125-
for (let i = 0; i < 4; i++) {
126-
if (view.getUint8(i) !== NES_HEADER[i]) {
127-
return false;
128-
}
129-
}
130-
return true;
131-
};
132-
133122
// Append a NES header to a PRG buffer.
134123
const prependNesHeader = (prg) => {
135124
const rom = new ArrayBuffer(NES_HEADER.length + prg.byteLength);
@@ -170,11 +159,4 @@ const expandRom = (rom) => {
170159
return newRom;
171160
};
172161

173-
export {
174-
loadRom,
175-
saveRom,
176-
inject,
177-
stringifyResources,
178-
hasNesHeader,
179-
expandRom,
180-
};
162+
export { loadRom, saveRom, inject, stringifyResources, expandRom };

src/lib/getResFromCrc32.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,9 @@ import res from './resources.js';
33
const isJapaneseVersion = (c) => c === 0x3da2085e || c === 0xf526cea8;
44
const getResFromCrc32 = (c) =>
55
res.find(({ crc32, crc32Rom }) => crc32 === c || crc32Rom === c) ?? null;
6+
const getResFromBaseRom = (b) =>
7+
res.find(
8+
({ metadata: { name } }) => name.toLowerCase() === b.toLowerCase(),
9+
) ?? null;
610

7-
export { isJapaneseVersion, getResFromCrc32 };
11+
export { isJapaneseVersion, getResFromCrc32, getResFromBaseRom };

src/lib/resources.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// prettier-ignore
22
const usa = {
3+
metadata: { name: 'USA' },
34
roomgfx: [
45
[0x04001, 0x03c9], [0x043ca, 0x069e], [0x04a68, 0x0327], [0x04d8f, 0x053b], [0x052ca, 0x06be],
56
[0x05988, 0x0682], [0x0600a, 0x0778], [0x06782, 0x0517], [0x06c99, 0x07fb], [0x07494, 0x07be],
@@ -109,6 +110,7 @@ const usa = {
109110
};
110111
// prettier-ignore
111112
const eur = {
113+
metadata: { name: 'Europe' },
112114
roomgfx: [
113115
[0x04001, 0x03b9], [0x043ba, 0x069e], [0x04a58, 0x0327], [0x04d7f, 0x053b], [0x052ba, 0x06be],
114116
[0x05978, 0x0682], [0x05ffa, 0x0778], [0x06772, 0x0517], [0x06c89, 0x07fb], [0x07484, 0x07be],
@@ -218,6 +220,7 @@ const eur = {
218220
};
219221
// prettier-ignore
220222
const swe = {
223+
metadata: { name: 'Sweden' },
221224
roomgfx: [
222225
[0x04001, 0x03f0], [0x043f1, 0x069e], [0x04a8f, 0x0327], [0x04db6, 0x053b], [0x052f1, 0x06be],
223226
[0x059af, 0x0682], [0x06031, 0x0778], [0x067a9, 0x0517], [0x06cc0, 0x07fb], [0x074bb, 0x07be],
@@ -326,6 +329,7 @@ const swe = {
326329
};
327330
// prettier-ignore
328331
const fra = {
332+
metadata: { name: 'France' },
329333
roomgfx: [
330334
[0x04001, 0x0426], [0x04427, 0x069e], [0x04ac5, 0x0327], [0x04dec, 0x053b], [0x05327, 0x06be],
331335
[0x059e5, 0x0682], [0x06067, 0x0778], [0x067df, 0x0517], [0x06cf6, 0x07fb], [0x074f1, 0x07be],
@@ -433,6 +437,7 @@ const fra = {
433437
};
434438
// prettier-ignore
435439
const ger = {
440+
metadata: { name: 'Germany' },
436441
roomgfx: [
437442
[0x04001, 0x0406], [0x04407, 0x069e], [0x04aa5, 0x0327], [0x04dcc, 0x053b], [0x05307, 0x06be],
438443
[0x059c5, 0x0682], [0x06047, 0x0778], [0x067bf, 0x0517], [0x06cd6, 0x07fb], [0x074d1, 0x07be],
@@ -541,6 +546,7 @@ const ger = {
541546
};
542547
// prettier-ignore
543548
const esp = {
549+
metadata: { name: 'Spain' },
544550
roomgfx: [
545551
[0x04001, 0x041b], [0x0441c, 0x069e], [0x04aba, 0x0327], [0x04de1, 0x053b], [0x0531c, 0x06be],
546552
[0x059da, 0x0682], [0x0605c, 0x0778], [0x067d4, 0x0517], [0x06ceb, 0x07fb], [0x074e6, 0x07be],
@@ -648,6 +654,7 @@ const esp = {
648654
};
649655
// prettier-ignore
650656
const ita = {
657+
metadata: { name: 'Italy' },
651658
roomgfx: [
652659
[0x04001, 0x03ef], [0x043f0, 0x069e], [0x04a8e, 0x0327], [0x04db5, 0x053b], [0x052f0, 0x06be],
653660
[0x059ae, 0x0682], [0x06030, 0x0778], [0x067a8, 0x0517], [0x06cbf, 0x07fb], [0x074ba, 0x07be],
@@ -754,6 +761,7 @@ const ita = {
754761
};
755762
// prettier-ignore
756763
const proto = {
764+
metadata: { name: 'Prototype' },
757765
roomgfx: [
758766
[0x04001, 0x03c9], [0x043ca, 0x069e], [0x04a68, 0x0327], [0x04d8f, 0x053b], [0x052ca, 0x06be],
759767
[0x05988, 0x0682], [0x0600a, 0x0778], [0x06782, 0x0517], [0x06c99, 0x07fb], [0x07494, 0x07be],

src/lib/utils.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,16 @@ const formatPercentage = (percent, decimals = 2) => {
2626
return `${(percent * 100).toFixed(dm)}%`;
2727
};
2828

29-
export { zeroPad, hex, formatBytes, formatPercentage };
29+
// Return true if an arrayBuffer has a NES header.
30+
const hasNesHeader = (bin) => {
31+
const NES_HEADER = new Uint8Array([0x4e, 0x45, 0x53, 0x1a]);
32+
const view = new DataView(bin);
33+
for (let i = 0; i < NES_HEADER.length; i++) {
34+
if (view.getUint8(i) !== NES_HEADER[i]) {
35+
return false;
36+
}
37+
}
38+
return true;
39+
};
40+
41+
export { zeroPad, hex, formatBytes, formatPercentage, hasNesHeader };

0 commit comments

Comments
 (0)