Skip to content

Latest commit

 

History

History
100 lines (76 loc) · 7.97 KB

File metadata and controls

100 lines (76 loc) · 7.97 KB

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.


Project Overview

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 build runs generatePatches.js first, then next build. The generate script walks /public/patches/{battle,map,portraits,game}/ and writes the corresponding *.json manifests.

Pages

  • pages/index.tsx — Landing page. Renders PlusPatcher component (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. Renders OptionalPatches component.
  • pages/classic.tsx — Classic patcher page. Renders ClassicPatcher.
  • 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.

Key Components & Hooks

  • components/PlusPatcher.tsx — Main patcher for index.tsx. No zip loading on mount. Accepts manifest: ExtractedManifest prop from index.tsx getStaticProps. Builds patch lists instantly from manifest. At download time, fetches individual .ips files from /extracted/. Exports ExtractedManifest type used by ClassicPatcher too.
  • components/ClassicPatcher.tsx — Classic variant. Same pattern as PlusPatcher but uses manifest.baseClassic and 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 .ips files via JSZip, returns categorized patch data. Used by PlusPatcher & ClassicPatcher.
  • hooks/useOptionalPatches.ts — Backward-compat wrapper around useZipPatches.
  • hooks/useStylePatches.ts — Wrapper around useZipPatches for 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 .ips from a zip ArrayBuffer.
  • generatePatches.js — Build-time script. Walks /public/patches/{battle,map,portraits,game}/ and writes *.json manifests. Run automatically before next build.

Patch Data Architecture

ulti.tsx (SSG — fast path)

  • /public/patches/battle/ — individual .ips + .png pairs (~182 entries)
  • /public/patches/map/ — ~62 entries
  • /public/patches/portraits/ — ~58 entries
  • /public/patches/fonts/ — 12 entries, with manifest.json
  • /public/patches/game/ — a few entries
  • /public/patches/{battle,map,portraits,game}.json — manifests generated by generatePatches.js
  • getStaticProps reads these JSON files at build time — data baked into page HTML, zero client fetches

PlusPatcher / index.tsx & ClassicPatcher / classic.tsx (extracted path — fast)

  • /public/extracted/manifest.json — generated by generatePatches.js. Lists all patch filenames per category. Read by getStaticProps at build time.
  • /public/extracted/base-plus/ — 4 CRC32-named .ips files from FF4UP.zip (fetched individually at download time)
  • /public/extracted/base-classic/ — 4 CRC32-named .ips files from FF4UC.zip
  • /public/extracted/styles/ — 13 .ips from Styles.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)

Known Gotchas

  • <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 in pages/_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 in layout.tsx by replacing with a React fragment (<>).

  • generatePatches.js must run before next build: The *.json manifests in /public/patches/ are generated at build time. If you add new .ips files to the patch directories, run npm run generate-patches (or just npm run build which does it automatically) before deploying. The fonts manifest is NOT auto-generated — it's manually maintained at /public/patches/fonts/manifest.json.

  • ulti.tsx getStaticProps uses require() not ESM import: fs and path are required inside the function body to avoid bundling them into client JS. TypeScript is satisfied with require('fs') as typeof import('fs').

  • ROM copier header: .smc files may have a 512-byte copier header (detectable by length % 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.data is a placeholder: StylePatch.data and OptionalPatch.data are Uint8Array in the panel interfaces, but PlusPatcher/ClassicPatcher set them to new Uint8Array(0). The panels never touch .data; only generatePatchedRom uses 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.


Next Steps / Roadmap

  • 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 ✓ — added loading="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.tsx stub — implement or remove

Open Questions

  • Does /optional page (which still uses useZipPatches / 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.js will need updating alongside CATEGORIES array in ulti.tsx.