Current Build Status (as of 2026-03-03) ✓ BUILDS CLEAN — all three patchers (/, /classic, /ulti) are SSG via getStaticProps. Zero zip loading on page mount.
NextJS 15 webapp for the FF4 Ultima romhack community (Final Fantasy IV SNES). Allows users to upload .sfc/.smc ROM files and apply .ips bytecode diff patches entirely client-side (no server processing). Deployed to Vercel (free tier, serverless + CDN).
- Stack: Next.js 15, React 19, TypeScript, Tailwind CSS 4, JSZip, Vercel
- Key constraint: All ROM/patch operations run in the browser. No backend compute.
- Build script:
npm run buildrunsgeneratePatches.jsfirst, thennext build. The generate script walks/public/patches/{battle,map,portraits,game}/and writes the corresponding*.jsonmanifests.
pages/index.tsx— Landing page. RendersPlusPatchercomponent (the "FF4 Ultima Plus Patcher"). Heavy zip loading here.pages/ulti.tsx— The "Ulti Patcher" — modular marketplace-style patch builder. Now SSG (getStaticProps). Immediate render, no client-side fetch loop.pages/optional.tsx— Optional features page. RendersOptionalPatchescomponent.pages/classic.tsx— Classic patcher page. RendersClassicPatcher.pages/patches.tsx— Patch archive listing page.pages/guides.tsx— Guides page.pages/discord.tsx— Discord link page.pages/overworldmap.tsx,underworldmap.tsx,lunarmap.tsx— Map viewer pages.
components/PlusPatcher.tsx— Main patcher for index.tsx. No zip loading on mount. Acceptsmanifest: ExtractedManifestprop from index.tsx getStaticProps. Builds patch lists instantly from manifest. At download time, fetches individual.ipsfiles from/extracted/. ExportsExtractedManifesttype used by ClassicPatcher too.components/ClassicPatcher.tsx— Classic variant. Same pattern as PlusPatcher but usesmanifest.baseClassicand only the Styles category. Accepts manifest from classic.tsx getStaticProps.components/OptionalPatches.tsx— Optional patches UI for optional.tsx.components/ApplyPatches.tsx— Stub component (button only, no patch logic). Placeholder.components/StylesPanel.tsx/CustomOptionsPanel.tsx— UI panels for style & optional patch selection.components/RomVerifier.tsx/ExtensibleRomVerifier.tsx— ROM upload + CRC32 validation.components/FileUploader.tsx/FileUpload2.tsx— ROM file input components.components/DownloadRomButton.tsx/DownloadRomButtonClassic.tsx— Download trigger components.components/SpinnerOverlay.tsx— Full-screen loading overlay.components/BothTitles.tsx,PlusTitle.tsx,ClassicTitle.tsx— Title image/heading components.components/NavBar.tsx— Site navigation.components/PreviewContainer.tsx,ImagePreviewModal.tsx— Patch preview image display.components/Map1/2/3.tsx— Map image viewers.hooks/useZipPatches.ts— Core hook: fetches a zip from/public, extracts.ipsfiles via JSZip, returns categorized patch data. Used by PlusPatcher & ClassicPatcher.hooks/useOptionalPatches.ts— Backward-compat wrapper arounduseZipPatches.hooks/useStylePatches.ts— Wrapper arounduseZipPatchesfor style patches.lib/IpsPatcher.ts/lib/patcher.ts— IPS patch application logic.lib/crc32.ts— CRC32 checksum calculation for ROM matching.lib/zipUtils.ts— JSZip helper: extract all.ipsfrom a zip ArrayBuffer.generatePatches.js— Build-time script. Walks/public/patches/{battle,map,portraits,game}/and writes*.jsonmanifests. Run automatically beforenext build.
/public/patches/battle/— individual.ips+.pngpairs (~182 entries)/public/patches/map/— ~62 entries/public/patches/portraits/— ~58 entries/public/patches/fonts/— 12 entries, withmanifest.json/public/patches/game/— a few entries/public/patches/{battle,map,portraits,game}.json— manifests generated bygeneratePatches.js- getStaticProps reads these JSON files at build time — data baked into page HTML, zero client fetches
/public/extracted/manifest.json— generated bygeneratePatches.js. Lists all patch filenames per category. Read by getStaticProps at build time./public/extracted/base-plus/— 4 CRC32-named.ipsfiles fromFF4UP.zip(fetched individually at download time)/public/extracted/base-classic/— 4 CRC32-named.ipsfiles fromFF4UC.zip/public/extracted/styles/— 13.ipsfromStyles.zip/public/extracted/battles/— 5,maps/— 3,portraits/— 4,fonts/— 12,tweaks/— 3- Source zips (
FF4UP.zip,Styles.zip, etc.) remain in/public/for user download, but are no longer fetched by the app at runtime - First-load JS reduced by ~38KB (JSZip removed from landing page bundle)
-
<html>/<body>in Layout breaks client-side navigation: In Next.js Pages Router, never render<html>or<body>tags in a component — those belong only inpages/_document. Doing so causes white-screen crashes on client-side navigation (via<Link>) because React tries to reconcile those tags against the live DOM. Direct URL access works because the browser's HTML parser is tolerant; React's DOM reconciler is not. Fixed inlayout.tsxby replacing with a React fragment (<>). -
generatePatches.js must run before next build: The
*.jsonmanifests in/public/patches/are generated at build time. If you add new.ipsfiles to the patch directories, runnpm run generate-patches(or justnpm run buildwhich does it automatically) before deploying. Thefontsmanifest is NOT auto-generated — it's manually maintained at/public/patches/fonts/manifest.json. -
ulti.tsx getStaticProps uses
require()not ESM import:fsandpathare required inside the function body to avoid bundling them into client JS. TypeScript is satisfied withrequire('fs') as typeof import('fs'). -
ROM copier header:
.smcfiles may have a 512-byte copier header (detectable bylength % 1024 === 512). Both patchers strip this before processing. -
ROM expansion: Both patchers expand ROMs smaller than 2MB to exactly 2MB before applying patches (FF4 Ultima requires 2MB address space).
-
patch.datais a placeholder:StylePatch.dataandOptionalPatch.dataareUint8Arrayin the panel interfaces, but PlusPatcher/ClassicPatcher set them tonew Uint8Array(0). The panels never touch.data; onlygeneratePatchedRomuses binary data, and it fetches from/extracted/at download time. If you see a future panel component that reads.data, it will silently apply nothing — guard with a size check if needed. -
ApplyPatches.tsx is a stub: The component has no patch logic — just a button that calls
onPatchesApplied(). Not currently used in production pages.
-
Apply getStaticProps pattern to✓ClassicPatcher.tsx -
Investigate PlusPatcher zip loading — pre-extract zip contents at build time✓ (generatePatches.js now extracts all zips; JSZip removed from runtime bundle for /, /classic) -
Image lazy-loading on ulti.tsx patch grid✓ — addedloading="lazy"to both<img>sites in ulti.tsx (category grid cards + selection preview panel). Fixes first-visit failure caused by ~315 simultaneous PNG requests blocking JS bundle hydration. -
ApplyPatches.tsxstub — implement or remove
- Does
/optionalpage (which still usesuseZipPatches/OptionalPatches.tsx) need the same SSG treatment? Currently it's○static, but still loads zips at mount. Lower priority since it's not the landing page. - Are there plans to add new patch categories to ulti.tsx? If so,
generatePatches.jswill need updating alongside CATEGORIES array inulti.tsx.