From 9747af2d739c2e934aac05fc2c99703ee433aee1 Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Wed, 18 May 2022 14:42:22 +0200 Subject: [PATCH] basic alttp patcher --- app/Http/Controllers/AlttpSeedController.php | 37 +++++ public/alttp-seeds | 1 + resources/js/components/App.js | 21 ++- .../components/alttp-seeds/BaseRomButton.js | 39 +++++ resources/js/components/alttp-seeds/Seed.js | 139 ++++++++++++++++++ resources/js/components/pages/AlttpSeed.js | 103 +++++++++++++ resources/js/helpers/AlttpBaseRomContext.js | 52 +++++++ resources/js/i18n/de.js | 43 ++++++ resources/js/i18n/en.js | 43 ++++++ routes/api.php | 3 + 10 files changed, 473 insertions(+), 8 deletions(-) create mode 100644 app/Http/Controllers/AlttpSeedController.php create mode 120000 public/alttp-seeds create mode 100644 resources/js/components/alttp-seeds/BaseRomButton.js create mode 100644 resources/js/components/alttp-seeds/Seed.js create mode 100644 resources/js/components/pages/AlttpSeed.js create mode 100644 resources/js/helpers/AlttpBaseRomContext.js diff --git a/app/Http/Controllers/AlttpSeedController.php b/app/Http/Controllers/AlttpSeedController.php new file mode 100644 index 0000000..571a44a --- /dev/null +++ b/app/Http/Controllers/AlttpSeedController.php @@ -0,0 +1,37 @@ +firstOrFail(); + + if ($seed->race) { + $seed->makeHidden('seed'); + } + if ($seed->mystery) { + $seed->makeHidden('settings'); + } + + return $seed->toJson(); + } + + public function retry($hash) { + $seed = AlttpSeed::where('hash', '=', $hash)->firstOrFail(); + + if ($seed->status == 'error') { + $seed->status = 'pending'; + $seed->save(); + Artisan::call('alttp:generate '.intval($seed->id)); + } + + return $seed->toJson(); + } + +} diff --git a/public/alttp-seeds b/public/alttp-seeds new file mode 120000 index 0000000..720b947 --- /dev/null +++ b/public/alttp-seeds @@ -0,0 +1 @@ +/home/holy/alttp/storage/app/alttp-seeds \ No newline at end of file diff --git a/resources/js/components/App.js b/resources/js/components/App.js index 817147f..e5d3578 100644 --- a/resources/js/components/App.js +++ b/resources/js/components/App.js @@ -3,8 +3,10 @@ import React, { useEffect, useState } from 'react'; import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import Header from './common/Header'; +import AlttpSeed from './pages/AlttpSeed'; import Tournament from './pages/Tournament'; import User from './pages/User'; +import AlttpBaseRomProvider from '../helpers/AlttpBaseRomContext'; import UserContext from '../helpers/UserContext'; const App = () => { @@ -48,14 +50,17 @@ const App = () => { }, []); return - -
- - } /> - } /> - } /> - - + + +
+ + } /> + } /> + } /> + } /> + + + ; }; diff --git a/resources/js/components/alttp-seeds/BaseRomButton.js b/resources/js/components/alttp-seeds/BaseRomButton.js new file mode 100644 index 0000000..e8954d5 --- /dev/null +++ b/resources/js/components/alttp-seeds/BaseRomButton.js @@ -0,0 +1,39 @@ +import React from 'react'; +import { Button } from 'react-bootstrap'; +import { withTranslation } from 'react-i18next'; + +import i18n from '../../i18n'; + +import { useAlttpBaseRom } from '../../helpers/AlttpBaseRomContext'; + +const BaseRomButton = () => { + const { rom, setRom } = useAlttpBaseRom(); + + const handleFile = React.useCallback(async e => { + if (e.target.files.length != 1) { + setRom(null); + } else { + const buf = await e.target.files[0].arrayBuffer(); + setRom(buf); + } + }, [setRom]); + + if (rom) return null; + + return + + + ; +}; + +export default withTranslation()(BaseRomButton); diff --git a/resources/js/components/alttp-seeds/Seed.js b/resources/js/components/alttp-seeds/Seed.js new file mode 100644 index 0000000..e804ae3 --- /dev/null +++ b/resources/js/components/alttp-seeds/Seed.js @@ -0,0 +1,139 @@ +import FileSaver from 'file-saver'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { Button, Col, Container, Row } from 'react-bootstrap'; +import { withTranslation } from 'react-i18next'; +import toastr from 'toastr'; + +import BaseRomButton from './BaseRomButton'; +import { useAlttpBaseRom } from '../../helpers/AlttpBaseRomContext'; +import BPS from '../../helpers/bps'; +import i18n from '../../i18n'; + +const applyPatch = (rom, patch, filename) => { + try { + const bps = new BPS(); + bps.setPatch(patch); + bps.setSource(rom); + const result = bps.applyPatch(); + FileSaver.saveAs(new Blob([result], { type: 'application/octet-stream' }), filename); + } catch (e) { + toastr.error(i18n.t('alttpSeeds.patchError', { msg: e.message })); + } +}; + +const isDefaultSetting = () => false; + +const Seed = ({ onRetry, patch, seed }) => { + const { rom } = useAlttpBaseRom(); + + return +

{i18n.t('alttpSeeds.heading')}

+ + + {rom ? + + : + + } + + +

+ {i18n.t('alttpSeeds.preset')}: + {' '} + {i18n.t(`alttpSeeds.presets.${seed.preset}`)} +

+ {seed.seed ? +

+ {i18n.t('alttpSeeds.seed')}: + {' '} + {seed.seed} +

+ : null} + {seed.race ? +

{i18n.t('alttpSeeds.race')}

+ : null} + {seed.mystery ? +

{i18n.t('alttpSeeds.mystery')}

+ : null} + {seed.status === 'generated' ? +

+ {i18n.t('alttpSeeds.generated')}: + {' '} + + {i18n.t('alttpSeeds.date', { date: new Date(seed.updated_at) })} + +

+ : +

+ {i18n.t('alttpSeeds.status')}: + {' '} + {i18n.t(`alttpSeeds.statuses.${seed.status}`)} +

+ } + {seed.status === 'error' ? +

+ +

+ : null} + +
+

{i18n.t('alttpSeeds.generator')}

+

{i18n.t(`alttpSeeds.generators.${seed.generator}`)}

+ {seed.settings ? <> +

{i18n.t('alttpSeeds.settings')}

+ + {Object.entries(seed.settings).map(([key, value]) => + + + {i18n.t(`alttpSeeds.settingName.${key}`)} + +
+ {isDefaultSetting(key, value) ? + i18n.t(`alttpSeeds.settingValue.${key}.${value}`) + : + {i18n.t(`alttpSeeds.settingValue.${key}.${value}`)} + } + + )} +
+ : null} +
; +}; + +Seed.propTypes = { + onRetry: PropTypes.func, + patch: PropTypes.instanceOf(ArrayBuffer), + seed: PropTypes.shape({ + generator: PropTypes.string, + hash: PropTypes.string, + mystery: PropTypes.bool, + preset: PropTypes.string, + race: PropTypes.bool, + seed: PropTypes.string, + settings: PropTypes.shape({ + }), + status: PropTypes.string, + updated_at: PropTypes.string, + }), +}; + +export default withTranslation()(Seed); diff --git a/resources/js/components/pages/AlttpSeed.js b/resources/js/components/pages/AlttpSeed.js new file mode 100644 index 0000000..96589d4 --- /dev/null +++ b/resources/js/components/pages/AlttpSeed.js @@ -0,0 +1,103 @@ +import axios from 'axios'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; + +import NotFound from './NotFound'; +import Seed from '../alttp-seeds/Seed'; +import ErrorBoundary from '../common/ErrorBoundary'; +import ErrorMessage from '../common/ErrorMessage'; +import Loading from '../common/Loading'; + +const AosSeed = () => { + const params = useParams(); + const { hash } = params; + + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [patch, setPatch] = useState(null); + const [seed, setSeed] = useState(null); + + const loadSeed = useCallback((hash, ctrl) => { + axios + .get(`/api/alttp-seed/${hash}`, { signal: ctrl.signal }) + .then(response => { + setError(null); + setLoading(false); + setSeed(response.data); + window.document.title = response.data.hash; + }) + .catch(error => { + setError(error); + setLoading(false); + setSeed(null); + }); + }, []); + + useEffect(() => { + setLoading(true); + const ctrl = new AbortController(); + loadSeed(hash, ctrl); + return () => { + ctrl.abort(); + }; + }, [hash]); + + useEffect(() => { + if (!seed || seed.status !== 'pending') { + return; + } + const ctrl = new AbortController(); + const timer = setTimeout(() => { + loadSeed(seed.hash, ctrl); + }, 2000); + return () => { + clearTimeout(timer); + ctrl.abort(); + }; + }, [seed]); + + useEffect(() => { + setPatch(null); + if (!seed || seed.status !== 'generated') { + return; + } + const ctrl = new AbortController(); + axios + .get(`/alttp-seeds/${hash}.bps`, { + responseType: 'arraybuffer', + signal: ctrl.signal, + }) + .then(response => { + setPatch(response.data); + }) + .catch(error => { + setError(error); + }); + return () => { + ctrl.abort(); + }; + }, [hash, seed]); + + const retry = useCallback(async () => { + await axios.post(`/api/alttp-seed/${hash}/retry`); + setSeed(seed => ({ ...seed, status: 'pending' })); + }); + + if (loading) { + return ; + } + + if (error) { + return ; + } + + if (!seed) { + return ; + } + + return + + ; +}; + +export default AosSeed; diff --git a/resources/js/helpers/AlttpBaseRomContext.js b/resources/js/helpers/AlttpBaseRomContext.js new file mode 100644 index 0000000..70ca6ad --- /dev/null +++ b/resources/js/helpers/AlttpBaseRomContext.js @@ -0,0 +1,52 @@ +import CRC32 from 'crc-32'; +import localforage from 'localforage'; +import PropTypes from 'prop-types'; +import React from 'react'; +import toastr from 'toastr'; + +import i18n from '../i18n'; + +const AlttpBaseRomContext = React.createContext(null); + +const AlttpBaseRomProvider = ({ children }) => { + const [rom, setRom] = React.useState(null); + + const setRomCallback = React.useCallback(buffer => { + if (buffer) { + const crc = CRC32.buf(new Uint8Array(buffer)); + if (crc === 0x3322EFFC) { + setRom(buffer); + localforage.setItem('alttpBaseRom', buffer); + toastr.success(i18n.t('alttp.baseRomSet')); + } else { + toastr.error(i18n.t('alttp.baseRomInvalid')); + } + } else { + setRom(null); + localforage.removeItem('alttpBaseRom'); + toastr.success(i18n.t('alttp.baseRomRemoved')); + } + }, [setRom]); + + React.useEffect(async () => { + const stored = await localforage.getItem('alttpBaseRom'); + if (stored) { + const crc = CRC32.buf(new Uint8Array(stored)); + if (crc == 0x3322EFFC) { + setRom(stored); + } + } + }, []); + + return + {children} + ; +}; + +AlttpBaseRomProvider.propTypes = { + children: PropTypes.node, +}; + +export const useAlttpBaseRom = () => React.useContext(AlttpBaseRomContext); + +export default AlttpBaseRomProvider; diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index a51c846..2ae950c 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -1,6 +1,49 @@ /* eslint-disable max-len */ export default { translation: { + alttp: { + baseRomInvalid: 'CRC32 Check fehlgeschlagen (brauche 33:22:EF:FC). Falsche ROM Datei?', + baseRomRemoved: 'Base ROM entfernt.', + baseRomSet: 'Base ROM gespeichert.', + setBaseRom: 'Base ROM auswählen', + }, + alttpSeeds: { + date: '{{ date, L LT }}', + fetchingPatch: 'Lade Patch', + filename: 'alttpr - {{preset}} - {{hash}}', + heading: 'A Link to the Past Randomizer Seed', + generated: 'Generiert', + generator: 'Generator', + generators: { + doors: 'Dieser Seed wurde mit dem Door Randomizer von Aerinon generiert', + }, + mystery: 'Mystery ROM, Einstellungen versteckt', + noMystery: 'Kein Mystery', + noRace: 'Kein Race', + patch: 'ROM patchen', + patchError: 'Fehler beim Patchen: {{msg}}', + preset: 'Preset', + presets: { + custom: 'Eigenes', + }, + race: 'Race ROM, Seed versteckt', + seed: 'Seed', + settingName: { + shuffleenemies: 'Enemy Shuffle', + }, + settings: 'Settings', + settingValue: { + shuffleenemies: { + shuffled: 'Shuffled', + }, + }, + status: 'Status', + statuses: { + error: 'Fehler', + generated: 'generiert', + pending: 'ausstehend', + }, + }, aos: { baseRomInvalid: 'CRC32 Check fehlgeschlagen (brauche 35:53:61:83). Falsche ROM Datei?', baseRomRemoved: 'Base ROM entfernt.', diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js index 64d38bb..a1b9966 100644 --- a/resources/js/i18n/en.js +++ b/resources/js/i18n/en.js @@ -1,6 +1,49 @@ /* eslint-disable max-len */ export default { translation: { + alttp: { + baseRomInvalid: 'CRC32 mismatch (need 33:22:EF:FC). Wrong ROM file?', + baseRomRemoved: 'Base ROM removed.', + baseRomSet: 'Base ROM set.', + setBaseRom: 'Set base ROM', + }, + alttpSeeds: { + date: '{{ date, L LT }}', + fetchingPatch: 'Fetching patch', + filename: 'alttpr - {{preset}} - {{hash}}', + heading: 'A Link to the Past Randomizer Seed', + generated: 'Generated', + generator: 'Generator', + generators: { + doors: 'This seed has been generated with Aerinon\'s door randomizer.', + }, + mystery: 'Mystery ROM, settings hidden', + noMystery: 'No mystery', + noRace: 'No race', + patch: 'Patch ROM', + patchError: 'Error applying patch: {{msg}}', + preset: 'Preset', + presets: { + custom: 'Custom', + }, + race: 'Race ROM, seed hidden', + seed: 'Seed', + settingName: { + shuffleenemies: 'Enemy shuffle', + }, + settings: 'Settings', + settingValue: { + shuffleenemies: { + shuffled: 'Shuffled', + }, + }, + status: 'Status', + statuses: { + error: 'error', + generated: 'generated', + pending: 'pending', + }, + }, aos: { baseRomInvalid: 'CRC32 mismatch (need 35:53:61:83). Wrong ROM file?', baseRomRemoved: 'Base ROM removed.', diff --git a/routes/api.php b/routes/api.php index f43505a..c5f18ee 100644 --- a/routes/api.php +++ b/routes/api.php @@ -18,6 +18,9 @@ Route::middleware('auth:sanctum')->get('/user', function (Request $request) { return $request->user(); }); +Route::get('alttp-seed/{hash}', 'App\Http\Controllers\AlttpSeedController@byHash'); +Route::post('alttp-seed/{hash}/retry', 'App\Http\Controllers\AlttpSeedController@retry'); + Route::get('aos-seed/{hash}', 'App\Http\Controllers\AosSeedController@byHash'); Route::post('aos-seed/{hash}/retry', 'App\Http\Controllers\AosSeedController@retry'); -- 2.39.2