Add BaseRomErrorModal on bad rom selections

This commit is contained in:
Jared Schoeny
2026-05-22 23:37:28 -06:00
parent 4a99825abe
commit 4117d4a220
6 changed files with 476 additions and 69 deletions

View File

@@ -0,0 +1,214 @@
"use client";
import React, { useEffect } from "react";
import { FiX } from "react-icons/fi";
import { FaCircleCheck, FaCircleXmark } from "react-icons/fa6";
export type BaseRomErrorModalState =
| { kind: "archive"; fileName: string; requiredExtensionPhrase: string; requiredRomName: string }
| { kind: "unrecognized"; fileName: string; requiredExtensionPhrase: string; requiredRomName: string }
| {
kind: "hash_mismatch";
fileName: string;
selectedHash: string;
requiredHash: string;
requiredRomName: string;
matchedRomName?: string;
};
interface BaseRomErrorModalProps {
state: BaseRomErrorModalState;
onClose: () => void;
}
function getTitle(state: BaseRomErrorModalState): string {
switch (state.kind) {
case "archive":
return "Extract your ROM first";
case "unrecognized":
return "Unrecognized file type";
case "hash_mismatch":
return "This isn't the correct base ROM";
}
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground/60">
{children}
</h3>
);
}
function HashBlock({
label,
hash,
variant,
}: {
label: string;
hash: string;
variant: "wrong" | "correct";
}) {
const isWrong = variant === "wrong";
const Icon = isWrong ? FaCircleXmark : FaCircleCheck;
return (
<div className="flex flex-col gap-1">
<span className="text-xs font-medium text-foreground/70">{label}</span>
<div
className={`flex items-center gap-2.5 rounded-md bg-[var(--surface-1)] px-3 py-2 ring-1 ring-inset ${
isWrong ? "ring-red-500/30" : "ring-emerald-500/30"
}`}
>
<Icon
className={`shrink-0 ${isWrong ? "text-red-500" : "text-emerald-500"}`}
size={14}
aria-hidden
/>
<div className="min-w-0 flex-1 overflow-x-auto">
<code className="whitespace-nowrap text-xs">{hash}</code>
</div>
</div>
</div>
);
}
function ModalBody({ state }: { state: BaseRomErrorModalState }) {
switch (state.kind) {
case "archive":
return (
<p className="text-sm leading-relaxed text-foreground/80">
Archive files like <code>.zip</code> need to be extracted first. After extracting, upload{" "}
<strong>{state.requiredExtensionPhrase}</strong> for <strong>{state.requiredRomName}</strong>.
</p>
);
case "unrecognized":
return (
<p className="text-sm leading-relaxed text-foreground/80">
<code>{state.fileName}</code> isn&apos;t a ROM file. This hack requires{" "}
<strong>{state.requiredRomName}</strong> — please select{" "}
<strong>{state.requiredExtensionPhrase}</strong>.
</p>
);
case "hash_mismatch":
return (
<div className="flex flex-col gap-6">
<section className="flex flex-col gap-2">
<SectionLabel>What happened</SectionLabel>
<p className="text-base leading-relaxed text-foreground/90">
{state.matchedRomName ? (
<>
This file looks like <strong>{state.matchedRomName}</strong>, but this hack needs{" "}
<strong>{state.requiredRomName}</strong>.
</>
) : (
<>
This file doesn&apos;t match a known clean copy of <strong>{state.requiredRomName}</strong>.
</>
)}
</p>
</section>
<div className="rounded-md border border-amber-500/50 bg-amber-500/10 p-3 text-sm leading-relaxed text-amber-900 dark:text-amber-100">
Patching against a non-matching ROM (even if that ROM is fully playable) can cause crashes, glitches, or broken saves.
</div>
<section className="flex flex-col gap-2">
<SectionLabel>Compare checksums</SectionLabel>
<div className="flex flex-col gap-3">
<HashBlock label="Your file" hash={state.selectedHash} variant="wrong" />
<HashBlock label={`Required — ${state.requiredRomName}`} hash={state.requiredHash} variant="correct" />
</div>
</section>
<section className="flex flex-col gap-2">
<SectionLabel>Common reasons</SectionLabel>
<ul className="list-disc space-y-1.5 pl-5 text-sm leading-relaxed text-foreground/80">
<li>Wrong revision or region</li>
<li>The ROM was modified or trimmed</li>
<li>The file is corrupted or incomplete</li>
</ul>
</section>
<div className="rounded-md border border-[var(--border)] bg-[var(--surface-2)] p-3 text-xs leading-relaxed text-foreground/60">
Hackdex cannot advise on where to find ROMs. The only legal source is ripping from your own cartridge.
</div>
</div>
);
}
}
const BaseRomErrorModal: React.FC<BaseRomErrorModalProps> = ({ state, onClose }) => {
useEffect(() => {
const html = document.documentElement;
const body = document.body;
const previousHtmlOverflow = html.style.overflow;
const previousBodyOverflow = body.style.overflow;
const previousBodyPaddingRight = body.style.paddingRight;
const scrollBarWidth = window.innerWidth - html.clientWidth;
html.style.overflow = "hidden";
body.style.overflow = "hidden";
if (scrollBarWidth > 0) {
body.style.paddingRight = `${scrollBarWidth}px`;
}
return () => {
html.style.overflow = previousHtmlOverflow;
body.style.overflow = previousBodyOverflow;
body.style.paddingRight = previousBodyPaddingRight;
};
}, []);
const title = getTitle(state);
return (
<div className="fixed inset-0 z-[100] flex md:items-center md:justify-center md:p-4">
<div
className="absolute inset-0 bg-black/50 dark:bg-black/60 md:backdrop-blur-sm"
onClick={onClose}
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="base-rom-error-title"
className="relative z-[101] flex h-full w-full flex-col card backdrop-blur-lg dark:!bg-black/70 rounded-none md:h-auto md:max-h-[85vh] md:w-full md:max-w-lg md:rounded-lg"
>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="absolute top-4 right-4 p-1.5 rounded-md text-foreground/60 hover:text-foreground hover:bg-[var(--surface-2)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
>
<FiX size={20} />
</button>
<div className="flex-1 overflow-y-auto p-6 pb-[max(1.5rem,env(safe-area-inset-bottom))]">
<h2 id="base-rom-error-title" className="text-xl font-semibold pr-8">
{title}
</h2>
{state.kind !== "unrecognized" && (
<p className="mt-2 text-sm text-foreground/80">
Selected file: <code>{state.fileName}</code>
</p>
)}
<div className="mt-6">
<ModalBody state={state} />
</div>
</div>
<div className="border-t border-[var(--border)] p-4 pb-[max(1rem,env(safe-area-inset-bottom))] md:border-0 md:p-6 md:pt-0">
<button
type="button"
onClick={onClose}
className="inline-flex h-14 md:h-11 w-full items-center justify-center rounded-md bg-[var(--accent)] px-4 text-sm font-semibold text-[var(--accent-foreground)] hover:bg-[var(--accent-700)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]"
>
Choose a different file
</button>
</div>
</div>
</div>
);
};
export default BaseRomErrorModal;

View File

@@ -2,12 +2,19 @@
import React from "react";
import StickyActionBar from "@/components/Hack/StickyActionBar";
import BaseRomErrorModal, { type BaseRomErrorModalState } from "@/components/Hack/BaseRomErrorModal";
import { useBaseRoms } from "@/contexts/BaseRomContext";
import { baseRoms } from "@/data/baseRoms";
import BinFile from "rom-patcher-js/rom-patcher-js/modules/BinFile.js";
import BPS from "rom-patcher-js/rom-patcher-js/modules/RomPatcher.format.bps.js";
import type { DownloadEventDetail } from "@/types/util";
import { getSignedPatchUrl, updatePatchDownloadCount } from "@/app/hack/[slug]/actions";
import { sha1Hex } from "@/utils/hash";
import {
formatRequiredRomExtension,
isArchiveFile,
isAnyRomExtension,
} from "@/utils/romFile";
interface HackActionsProps {
title: string;
@@ -37,7 +44,12 @@ const HackActions: React.FC<HackActionsProps> = ({
const [patchBlob, setPatchBlob] = React.useState<Blob | null>(null);
const [patchUrl, setPatchUrl] = React.useState<string | null>(null);
const [termsAgreed, setTermsAgreed] = React.useState(false);
const [romErrorModal, setRomErrorModal] = React.useState<BaseRomErrorModalState | null>(null);
const baseRomName = React.useMemo(() => baseRoms.find(r => r.id === baseRomId)?.name || null, [baseRomId]);
const effectivePlatform = React.useMemo(
() => platform ?? baseRoms.find(r => r.id === baseRomId)?.platform,
[platform, baseRomId],
);
// Basic client-side bot detection
React.useEffect(() => {
@@ -95,23 +107,74 @@ const HackActions: React.FC<HackActionsProps> = ({
async function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0] ?? null;
setFile(f);
if (f) {
const id = await importUploadedBlob(f);
if (!id) {
setError("That ROM doesn't match any supported base ROM.");
setStatus("idle");
e.target.value = "";
return;
}
if (id !== baseRomId) {
setError(`This ROM matches "${id}", but this hack requires "${baseRomName}".`);
setStatus("idle");
e.target.value = "";
return;
}
setStatus("ready");
setFile(null);
if (!f) return;
const requiredRomName = baseRomName ?? "this hack's base ROM";
const requiredExtensionPhrase = effectivePlatform
? formatRequiredRomExtension(effectivePlatform)
: "a ROM file";
const resetInput = () => {
e.target.value = "";
setStatus("idle");
};
if (isArchiveFile(f.name)) {
setRomErrorModal({
kind: "archive",
fileName: f.name,
requiredExtensionPhrase,
requiredRomName,
});
resetInput();
return;
}
if (!isAnyRomExtension(f.name)) {
setRomErrorModal({
kind: "unrecognized",
fileName: f.name,
requiredExtensionPhrase,
requiredRomName,
});
resetInput();
return;
}
const selectedHash = await sha1Hex(f);
const match = baseRoms.find((r) => r.sha1.toLowerCase() === selectedHash.toLowerCase());
const requiredHash = baseRoms.find((r) => r.id === baseRomId)?.sha1.toLowerCase() ?? "";
if (!match) {
setRomErrorModal({
kind: "hash_mismatch",
fileName: f.name,
selectedHash,
requiredHash,
requiredRomName,
});
resetInput();
return;
}
if (match.id !== baseRomId) {
await importUploadedBlob(f);
setRomErrorModal({
kind: "hash_mismatch",
fileName: f.name,
selectedHash,
requiredHash,
requiredRomName,
matchedRomName: match.name,
});
resetInput();
return;
}
await importUploadedBlob(f);
setFile(f);
setStatus("ready");
}
async function onAgreeToTerms() {
@@ -244,23 +307,31 @@ const HackActions: React.FC<HackActionsProps> = ({
}
return (
<StickyActionBar
title={title}
version={version}
author={author}
filename={patchFilename}
baseRomName={baseRomName}
baseRomPlatform={platform}
onPatch={onPatch}
status={status}
error={error}
isLinked={isLinked(baseRomId)}
romReady={hasPermission(baseRomId) || hasCached(baseRomId)}
onClickLink={() => (isLinked(baseRomId) ? ensurePermission(baseRomId, true) : linkRom(baseRomId))}
supported={supported}
onUploadChange={onSelectFile}
termsAgreed={termsAgreed}
/>
<>
<StickyActionBar
title={title}
version={version}
author={author}
filename={patchFilename}
baseRomName={baseRomName}
baseRomPlatform={platform}
onPatch={onPatch}
status={status}
error={error}
isLinked={isLinked(baseRomId)}
romReady={hasPermission(baseRomId) || hasCached(baseRomId)}
onClickLink={() => (isLinked(baseRomId) ? ensurePermission(baseRomId, true) : linkRom(baseRomId))}
supported={supported}
onUploadChange={onSelectFile}
termsAgreed={termsAgreed}
/>
{romErrorModal && (
<BaseRomErrorModal
state={romErrorModal}
onClose={() => setRomErrorModal(null)}
/>
)}
</>
);
};

View File

@@ -1,7 +1,7 @@
export async function sha1Hex(blob: Blob): Promise<string> {
const buf = await blob.arrayBuffer();
// @ts-ignore - subtle may be undefined in non-browser
const digest = await (crypto.subtle as SubtleCrypto).digest("SHA-1", buf);
import { sha1 } from "js-sha1";
async function subtleSha1Hex(buf: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest("SHA-1", buf);
const bytes = new Uint8Array(digest);
let hex = "";
for (let i = 0; i < bytes.length; i++) {
@@ -10,4 +10,16 @@ export async function sha1Hex(blob: Blob): Promise<string> {
return hex;
}
export async function sha1Hex(blob: Blob): Promise<string> {
const buf = await blob.arrayBuffer();
if (typeof crypto !== "undefined" && typeof crypto.subtle?.digest === "function") {
try {
return await subtleSha1Hex(buf);
} catch {
// crypto.subtle can fail outside a secure context (e.g. http dev)
}
}
return sha1(buf);
}

50
src/utils/romFile.ts Normal file
View File

@@ -0,0 +1,50 @@
import type { Platform } from "@/data/baseRoms";
import { platformAccept, platformAcceptAll } from "@/utils/idb";
const ARCHIVE_EXTENSIONS = new Set([
".zip",
".7z",
".rar",
".tar",
".gz",
".gzip",
".bz2",
".xz",
".cab",
]);
export function getFileExtension(name: string): string {
const lower = name.toLowerCase();
const lastDot = lower.lastIndexOf(".");
if (lastDot <= 0) return "";
return lower.slice(lastDot);
}
export function isArchiveFile(name: string): boolean {
return ARCHIVE_EXTENSIONS.has(getFileExtension(name));
}
function platformExtensions(platform: Platform): string[] {
return platformAccept(platform)
.split(",")
.map((ext) => ext.trim())
.filter(Boolean);
}
/** Any extension Hackdex accepts across all platforms (.gb, .gbc, .gba, .nds). */
export function isAnyRomExtension(name: string): boolean {
const ext = getFileExtension(name);
if (!ext) return false;
return platformAcceptAll()
.split(",")
.map((e) => e.trim())
.includes(ext);
}
export function formatRequiredRomExtension(platform: Platform): string {
const exts = platformExtensions(platform);
if (exts.length === 0) return "a ROM file";
if (exts.length === 1) return `a ${exts[0]} file`;
const formatted = exts.map((ext) => ext).join(" or ");
return `a ${formatted} file`;
}