Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: automatic patching #236

Merged
merged 1 commit into from
Jan 3, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
feat: automatic patching
- support patch file uploads

- patches must be named the same as the rom

- patches matching the above criteria are loaded automatically

- bumps mGBA wasm core
thenick775 committed Jan 3, 2025
commit f290daba8b9365c83d4d1a13aabd0851a6d08818
8 changes: 4 additions & 4 deletions gbajs3/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion gbajs3/package.json
Original file line number Diff line number Diff line change
@@ -18,7 +18,7 @@
"@emotion/styled": "^11.13.5",
"@mui/material": "^6.0.2",
"@mui/x-tree-view": "^7.0.0",
"@thenick775/mgba-wasm": "^1.0.20",
"@thenick775/mgba-wasm": "^1.1.0",
"@uidotdev/usehooks": "^2.4.1",
"jwt-decode": "^4.0.0",
"nanoid": "^5.0.7",
6 changes: 3 additions & 3 deletions gbajs3/src/components/modals/upload-cheats.tsx
Original file line number Diff line number Diff line change
@@ -38,9 +38,9 @@ export const UploadCheatsModal = () => {
await Promise.all(
cheatFiles.map(
(cheatFile) =>
new Promise<void>((resolve) => {
emulator?.uploadCheats(cheatFile, resolve);
})
new Promise<void>((resolve) =>
emulator?.uploadCheats(cheatFile, resolve)
)
)
);

215 changes: 215 additions & 0 deletions gbajs3/src/components/modals/upload-patches.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';

import { UploadPatchesModal } from './upload-patches.tsx';
import { renderWithContext } from '../../../test/render-with-context.tsx';
import * as contextHooks from '../../hooks/context.tsx';
import * as addCallbackHooks from '../../hooks/emulator/use-add-callbacks.tsx';
import { productTourLocalStorageKey } from '../product-tour/consts.tsx';

import type { GBAEmulator } from '../../emulator/mgba/mgba-emulator.tsx';

describe('<UploadPatchesModal />', () => {
it('uploads file and closes modal', async () => {
const uploadPatchSpy: (file: File, cb?: () => void) => void = vi.fn(
(_file, cb) => cb && cb()
);
const syncActionIfEnabledSpy = vi.fn();
const setIsModalOpenSpy = vi.fn();

const {
useEmulatorContext: originalEmulator,
useModalContext: originalModal
} = await vi.importActual<typeof contextHooks>('../../hooks/context.tsx');
const { useAddCallbacks: originalCallbacks } = await vi.importActual<
typeof addCallbackHooks
>('../../hooks/emulator/use-add-callbacks.tsx');

vi.spyOn(contextHooks, 'useEmulatorContext').mockImplementation(() => ({
...originalEmulator(),
emulator: {
uploadPatch: uploadPatchSpy
} as GBAEmulator
}));

vi.spyOn(addCallbackHooks, 'useAddCallbacks').mockImplementation(() => ({
...originalCallbacks(),
syncActionIfEnabled: syncActionIfEnabledSpy
}));

vi.spyOn(contextHooks, 'useModalContext').mockImplementation(() => ({
...originalModal(),
setIsModalOpen: setIsModalOpenSpy
}));

const testPatchFile = new File(['Some patch file contents'], 'rom1.ips');

renderWithContext(<UploadPatchesModal />);

const patchInput = screen.getByTestId('hidden-file-input');

expect(patchInput).toBeInTheDocument();

await userEvent.upload(patchInput, testPatchFile);

expect(screen.getByText('File to upload:')).toBeVisible();
expect(screen.getByText('rom1.ips')).toBeVisible();

await userEvent.click(screen.getByRole('button', { name: 'Upload' }));

expect(uploadPatchSpy).toHaveBeenCalledOnce();
expect(uploadPatchSpy).toHaveBeenCalledWith(
testPatchFile,
expect.anything()
);
expect(syncActionIfEnabledSpy).toHaveBeenCalledOnce();
expect(setIsModalOpenSpy).toHaveBeenCalledWith(false);
});

it('uploads multiple files and closes modal', async () => {
const uploadPatchSpy: (file: File, cb?: () => void) => void = vi.fn(
(_file, cb) => cb && cb()
);
const syncActionIfEnabledSpy = vi.fn();
const setIsModalOpenSpy = vi.fn();

const {
useEmulatorContext: originalEmulator,
useModalContext: originalModal
} = await vi.importActual<typeof contextHooks>('../../hooks/context.tsx');
const { useAddCallbacks: originalCallbacks } = await vi.importActual<
typeof addCallbackHooks
>('../../hooks/emulator/use-add-callbacks.tsx');

vi.spyOn(contextHooks, 'useEmulatorContext').mockImplementation(() => ({
...originalEmulator(),
emulator: {
uploadPatch: uploadPatchSpy
} as GBAEmulator
}));

vi.spyOn(addCallbackHooks, 'useAddCallbacks').mockImplementation(() => ({
...originalCallbacks(),
syncActionIfEnabled: syncActionIfEnabledSpy
}));

vi.spyOn(contextHooks, 'useModalContext').mockImplementation(() => ({
...originalModal(),
setIsModalOpen: setIsModalOpenSpy
}));

const testPatchFiles = [
new File(['Some patch file contents 1'], 'rom1.ips'),
new File(['Some patch file contents 2'], 'rom2.ips')
];

renderWithContext(<UploadPatchesModal />);

const patchInput = screen.getByTestId('hidden-file-input');

expect(patchInput).toBeInTheDocument();

await userEvent.upload(patchInput, testPatchFiles);

expect(screen.getByText('Files to upload:')).toBeVisible();
expect(screen.getByText('rom1.ips')).toBeVisible();
expect(screen.getByText('rom2.ips')).toBeVisible();

await userEvent.click(screen.getByRole('button', { name: 'Upload' }));

expect(uploadPatchSpy).toHaveBeenCalledTimes(2);
expect(uploadPatchSpy).toHaveBeenCalledWith(
testPatchFiles[0],
expect.anything()
);
expect(uploadPatchSpy).toHaveBeenCalledWith(
testPatchFiles[1],
expect.anything()
);
expect(syncActionIfEnabledSpy).toHaveBeenCalledOnce();
expect(setIsModalOpenSpy).toHaveBeenCalledWith(false);
});

it('renders form validation error', async () => {
renderWithContext(<UploadPatchesModal />);

await userEvent.click(screen.getByRole('button', { name: 'Upload' }));

expect(
screen.getByText(/At least one .ips\/.ups\/.bps file is required/)
).toBeVisible();
});

it('closes modal using the close button', async () => {
const setIsModalOpenSpy = vi.fn();
const { useModalContext: original } = await vi.importActual<
typeof contextHooks
>('../../hooks/context.tsx');

vi.spyOn(contextHooks, 'useModalContext').mockImplementation(() => ({
...original(),
setIsModalOpen: setIsModalOpenSpy
}));

renderWithContext(<UploadPatchesModal />);

// click the close button
const closeButton = screen.getByText('Close', { selector: 'button' });
expect(closeButton).toBeInTheDocument();
await userEvent.click(closeButton);

expect(setIsModalOpenSpy).toHaveBeenCalledWith(false);
});

it('renders tour steps', async () => {
const { useModalContext: original } = await vi.importActual<
typeof contextHooks
>('../../hooks/context.tsx');

vi.spyOn(contextHooks, 'useModalContext').mockImplementation(() => ({
...original(),
isModalOpen: true
}));

localStorage.setItem(
productTourLocalStorageKey,
'{"hasCompletedProductTourIntro":"finished"}'
);

renderWithContext(<UploadPatchesModal />);

expect(
await screen.findByText(
'Use this area to drag and drop .ips/.ups/.bps patch files, or click to select files.'
)
).toBeInTheDocument();
expect(
screen.getByText(
'The name of your patch files must match the name of your rom.'
)
).toBeInTheDocument();
expect(
screen.getByText('You may drop or select multiple files!')
).toBeInTheDocument();

// click joyride floater
await userEvent.click(
screen.getByRole('button', { name: 'Open the dialog' })
);

expect(
screen.getByText(
'Use this area to drag and drop .ips/.ups/.bps patch files, or click to select files.'
)
).toBeVisible();
expect(
screen.getByText(
'The name of your patch files must match the name of your rom.'
)
).toBeVisible();
expect(
screen.getByText('You may drop or select multiple files!')
).toBeVisible();
});
});
115 changes: 115 additions & 0 deletions gbajs3/src/components/modals/upload-patches.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { Button } from '@mui/material';
import { useCallback, useId } from 'react';
import { Controller, useForm, type SubmitHandler } from 'react-hook-form';

import { ModalBody } from './modal-body.tsx';
import { ModalFooter } from './modal-footer.tsx';
import { ModalHeader } from './modal-header.tsx';
import { useEmulatorContext, useModalContext } from '../../hooks/context.tsx';
import { useAddCallbacks } from '../../hooks/emulator/use-add-callbacks.tsx';
import {
EmbeddedProductTour,
type TourSteps
} from '../product-tour/embedded-product-tour.tsx';
import { DragAndDropInput } from '../shared/drag-and-drop-input.tsx';

type InputProps = {
patchFiles: File[];
};

const validFileExtensions = ['.ips', '.ups', '.bps'];

export const UploadPatchesModal = () => {
const { setIsModalOpen } = useModalContext();
const { emulator } = useEmulatorContext();
const { syncActionIfEnabled } = useAddCallbacks();
const { reset, handleSubmit, setValue, control } = useForm<InputProps>();
const uploadPatchesFormId = useId();

const onDrop = useCallback(
(acceptedFiles: File[]) => {
reset();
setValue('patchFiles', acceptedFiles, { shouldValidate: true });
},
[reset, setValue]
);

const onSubmit: SubmitHandler<InputProps> = async ({ patchFiles }) => {
await Promise.all(
patchFiles.map(
(patchFile) =>
new Promise<void>((resolve) =>
emulator?.uploadPatch(patchFile, resolve)
)
)
);

await syncActionIfEnabled();
setIsModalOpen(false);
};

const tourSteps: TourSteps = [
{
content: (
<>
<p>
Use this area to drag and drop .ips/.ups/.bps patch files, or click
to select files.
</p>
<p>The name of your patch files must match the name of your rom.</p>
<p>You may drop or select multiple files!</p>
</>
),
target: `#${CSS.escape(`${uploadPatchesFormId}--drag-and-drop`)}`
}
];

return (
<>
<ModalHeader title="Upload Patches" />
<ModalBody>
<form
id={uploadPatchesFormId}
aria-label="Upload Patches Form"
onSubmit={handleSubmit(onSubmit)}
>
<Controller
control={control}
name="patchFiles"
rules={{
validate: (patchFiles) =>
patchFiles?.length > 0 ||
'At least one .ips/.ups/.bps file is required'
}}
render={({ field: { name, value }, fieldState: { error } }) => (
<DragAndDropInput
ariaLabel="Upload Patches"
id={`${uploadPatchesFormId}--drag-and-drop`}
onDrop={onDrop}
name={name}
validFileExtensions={validFileExtensions}
error={error?.message}
hideAcceptedFiles={!value?.length}
multiple
>
<p>Drag and drop patch files here, or click to upload files</p>
</DragAndDropInput>
)}
/>
</form>
</ModalBody>
<ModalFooter>
<Button form={uploadPatchesFormId} type="submit" variant="contained">
Upload
</Button>
<Button variant="outlined" onClick={() => setIsModalOpen(false)}>
Close
</Button>
</ModalFooter>
<EmbeddedProductTour
steps={tourSteps}
completedProductTourStepName="hasCompletedUploadPatchesTour"
/>
</>
);
};
6 changes: 3 additions & 3 deletions gbajs3/src/components/modals/upload-saves.tsx
Original file line number Diff line number Diff line change
@@ -41,9 +41,9 @@ export const UploadSavesModal = () => {
await Promise.all(
saveFiles.map(
(saveFile) =>
new Promise<void>((resolve) => {
emulator?.uploadSaveOrSaveState(saveFile, resolve);
})
new Promise<void>((resolve) =>
emulator?.uploadSaveOrSaveState(saveFile, resolve)
)
)
);

Original file line number Diff line number Diff line change
@@ -21,6 +21,7 @@ import { LoadSaveModal } from '../modals/load-save.tsx';
import { LoginModal } from '../modals/login.tsx';
import { SaveStatesModal } from '../modals/save-states.tsx';
import { UploadCheatsModal } from '../modals/upload-cheats.tsx';
import { UploadPatchesModal } from '../modals/upload-patches.tsx';
import { UploadRomToServerModal } from '../modals/upload-rom-to-server.tsx';
import { UploadRomModal } from '../modals/upload-rom.tsx';
import { UploadSaveToServerModal } from '../modals/upload-save-to-server.tsx';
@@ -35,6 +36,8 @@ describe('<NavigationMenu />', () => {
expect(screen.getByRole('list', { name: 'Menu' })).toBeInTheDocument();
expect(screen.getByLabelText('Menu Toggle')).toBeInTheDocument();
expect(screen.getByLabelText('Menu Dismiss')).toBeInTheDocument();
// renders default mounted menu items
expect(screen.getAllByRole('listitem')).toHaveLength(13);
});

it('toggles menu with button', async () => {
@@ -86,6 +89,7 @@ describe('<NavigationMenu />', () => {
['About', <AboutModal />],
['Upload Saves', <UploadSavesModal />],
['Upload Cheats', <UploadCheatsModal />],
['Upload Patches', <UploadPatchesModal />],
['Upload Rom', <UploadRomModal />],
['Load Local Rom', <LoadLocalRomModal />],
['Controls', <ControlsModal />],
10 changes: 10 additions & 0 deletions gbajs3/src/components/navigation-menu/navigation-menu.tsx
Original file line number Diff line number Diff line change
@@ -48,6 +48,7 @@ import { LoadSaveModal } from '../modals/load-save.tsx';
import { LoginModal } from '../modals/login.tsx';
import { SaveStatesModal } from '../modals/save-states.tsx';
import { UploadCheatsModal } from '../modals/upload-cheats.tsx';
import { UploadPatchesModal } from '../modals/upload-patches.tsx';
import { UploadRomToServerModal } from '../modals/upload-rom-to-server.tsx';
import { UploadRomModal } from '../modals/upload-rom.tsx';
import { UploadSaveToServerModal } from '../modals/upload-save-to-server.tsx';
@@ -218,6 +219,15 @@ export const NavigationMenu = () => {
setIsModalOpen(true);
}}
/>
<NavLeaf
title="Upload Patches"
$disabled={isRunning}
icon={<BiCloudUpload />}
onClick={() => {
setModalContent(<UploadPatchesModal />);
setIsModalOpen(true);
}}
/>
<NavLeaf
title="Upload Rom"
$disabled={isRunning}
8 changes: 3 additions & 5 deletions gbajs3/src/emulator/mgba/mgba-emulator.tsx
Original file line number Diff line number Diff line change
@@ -4,10 +4,6 @@ import type {
mGBAEmulator as mGBAEmulatorTypeDef
} from '@thenick775/mgba-wasm';

interface FsNode extends FS.FSNode {
mode: number;
}

export type KeyBinding = {
gbaInput: string; // represents the GBA input to be remapped
key: string; // represents the key property of a browser KeyboardEvent
@@ -66,6 +62,7 @@ export type GBAEmulator = {
simulateKeyDown: (keyId: string) => void;
simulateKeyUp: (keyId: string) => void;
uploadCheats: (file: File, callback?: () => void) => void;
uploadPatch: (file: File, callback?: () => void) => void;
uploadRom: (file: File, callback?: () => void) => void;
uploadSaveOrSaveState: (file: File, callback?: () => void) => void;
};
@@ -98,7 +95,7 @@ export const mGBAEmulator = (mGBA: mGBAEmulatorTypeDef): GBAEmulator => {
if (ignorePaths.includes(name)) continue;

const currPath = `${path}/${name}`;
const { mode } = mGBA.FS.lookupPath(currPath, {}).node as FsNode;
const { mode } = mGBA.FS.lookupPath(currPath, {}).node;
const fileNode = {
path: currPath,
isDir: mGBA.FS.isDir(mode),
@@ -241,6 +238,7 @@ export const mGBAEmulator = (mGBA: mGBAEmulatorTypeDef): GBAEmulator => {
getCurrentSaveName: () => filepathToFileName(mGBA.saveName),
getFile: (path) => mGBA.FS.readFile(path),
uploadCheats: mGBA.uploadCheats,
uploadPatch: mGBA.uploadPatch,
uploadRom: mGBA.uploadRom,
uploadSaveOrSaveState: mGBA.uploadSaveOrSaveState,
deleteSaveState: (slot) => {