From c5c4bd06dcaea7eb1b211d6758a24a6b2bee21ff Mon Sep 17 00:00:00 2001 From: Nicholas VanCise <40526638+thenick775@users.noreply.github.com> Date: Thu, 2 Jan 2025 21:21:53 -0800 Subject: [PATCH] feat: automatic patching (#236) - 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 --- gbajs3/package-lock.json | 8 +- gbajs3/package.json | 2 +- .../src/components/modals/upload-cheats.tsx | 6 +- .../components/modals/upload-patches.spec.tsx | 215 ++++++++++++++++++ .../src/components/modals/upload-patches.tsx | 115 ++++++++++ gbajs3/src/components/modals/upload-saves.tsx | 6 +- .../navigation-menu/navigation-menu.spec.tsx | 4 + .../navigation-menu/navigation-menu.tsx | 10 + gbajs3/src/emulator/mgba/mgba-emulator.tsx | 8 +- 9 files changed, 358 insertions(+), 16 deletions(-) create mode 100644 gbajs3/src/components/modals/upload-patches.spec.tsx create mode 100644 gbajs3/src/components/modals/upload-patches.tsx diff --git a/gbajs3/package-lock.json b/gbajs3/package-lock.json index dbf03e3f..e6cd83a3 100644 --- a/gbajs3/package-lock.json +++ b/gbajs3/package-lock.json @@ -12,7 +12,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", @@ -3633,9 +3633,9 @@ } }, "node_modules/@thenick775/mgba-wasm": { - "version": "1.0.21", - "resolved": "https://registry.npmjs.org/@thenick775/mgba-wasm/-/mgba-wasm-1.0.21.tgz", - "integrity": "sha512-gEyxn5tYRER/nqDanTzZdNQo4rxBY61+WFJ5RorJ+01zIVhoiMZTZXEPaOFv9UhqQYcXzcvpdZs/ONus6Ekbag==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@thenick775/mgba-wasm/-/mgba-wasm-1.1.0.tgz", + "integrity": "sha512-eTgLpA7y/Vo48yV7S2bjiZ3QVPGYBa1NSJmlD1VRhaNBHQ7b5C6telz/epo0CjGofitr9vZSFkFdAx1bYYSoCQ==", "license": "MPL-2.0" }, "node_modules/@types/aria-query": { diff --git a/gbajs3/package.json b/gbajs3/package.json index 3033848b..67b2aee7 100644 --- a/gbajs3/package.json +++ b/gbajs3/package.json @@ -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", diff --git a/gbajs3/src/components/modals/upload-cheats.tsx b/gbajs3/src/components/modals/upload-cheats.tsx index 4550b442..1a0adf01 100644 --- a/gbajs3/src/components/modals/upload-cheats.tsx +++ b/gbajs3/src/components/modals/upload-cheats.tsx @@ -38,9 +38,9 @@ export const UploadCheatsModal = () => { await Promise.all( cheatFiles.map( (cheatFile) => - new Promise((resolve) => { - emulator?.uploadCheats(cheatFile, resolve); - }) + new Promise((resolve) => + emulator?.uploadCheats(cheatFile, resolve) + ) ) ); diff --git a/gbajs3/src/components/modals/upload-patches.spec.tsx b/gbajs3/src/components/modals/upload-patches.spec.tsx new file mode 100644 index 00000000..22187260 --- /dev/null +++ b/gbajs3/src/components/modals/upload-patches.spec.tsx @@ -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('', () => { + 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('../../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(); + + 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('../../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(); + + 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(); + + 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(); + + // 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(); + + 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(); + }); +}); diff --git a/gbajs3/src/components/modals/upload-patches.tsx b/gbajs3/src/components/modals/upload-patches.tsx new file mode 100644 index 00000000..7341dc42 --- /dev/null +++ b/gbajs3/src/components/modals/upload-patches.tsx @@ -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(); + const uploadPatchesFormId = useId(); + + const onDrop = useCallback( + (acceptedFiles: File[]) => { + reset(); + setValue('patchFiles', acceptedFiles, { shouldValidate: true }); + }, + [reset, setValue] + ); + + const onSubmit: SubmitHandler = async ({ patchFiles }) => { + await Promise.all( + patchFiles.map( + (patchFile) => + new Promise((resolve) => + emulator?.uploadPatch(patchFile, resolve) + ) + ) + ); + + await syncActionIfEnabled(); + setIsModalOpen(false); + }; + + const tourSteps: TourSteps = [ + { + content: ( + <> +

+ Use this area to drag and drop .ips/.ups/.bps patch files, or click + to select files. +

+

The name of your patch files must match the name of your rom.

+

You may drop or select multiple files!

+ + ), + target: `#${CSS.escape(`${uploadPatchesFormId}--drag-and-drop`)}` + } + ]; + + return ( + <> + + +
+ + patchFiles?.length > 0 || + 'At least one .ips/.ups/.bps file is required' + }} + render={({ field: { name, value }, fieldState: { error } }) => ( + +

Drag and drop patch files here, or click to upload files

+
+ )} + /> + +
+ + + + + + + ); +}; diff --git a/gbajs3/src/components/modals/upload-saves.tsx b/gbajs3/src/components/modals/upload-saves.tsx index b1d4e822..25b93cd8 100644 --- a/gbajs3/src/components/modals/upload-saves.tsx +++ b/gbajs3/src/components/modals/upload-saves.tsx @@ -41,9 +41,9 @@ export const UploadSavesModal = () => { await Promise.all( saveFiles.map( (saveFile) => - new Promise((resolve) => { - emulator?.uploadSaveOrSaveState(saveFile, resolve); - }) + new Promise((resolve) => + emulator?.uploadSaveOrSaveState(saveFile, resolve) + ) ) ); diff --git a/gbajs3/src/components/navigation-menu/navigation-menu.spec.tsx b/gbajs3/src/components/navigation-menu/navigation-menu.spec.tsx index bc4739b9..e83ae184 100644 --- a/gbajs3/src/components/navigation-menu/navigation-menu.spec.tsx +++ b/gbajs3/src/components/navigation-menu/navigation-menu.spec.tsx @@ -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('', () => { 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('', () => { ['About', ], ['Upload Saves', ], ['Upload Cheats', ], + ['Upload Patches', ], ['Upload Rom', ], ['Load Local Rom', ], ['Controls', ], diff --git a/gbajs3/src/components/navigation-menu/navigation-menu.tsx b/gbajs3/src/components/navigation-menu/navigation-menu.tsx index 5bc9727f..b5951126 100644 --- a/gbajs3/src/components/navigation-menu/navigation-menu.tsx +++ b/gbajs3/src/components/navigation-menu/navigation-menu.tsx @@ -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); }} /> + } + onClick={() => { + setModalContent(); + setIsModalOpen(true); + }} + /> 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) => {