summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJeod <47716344+JeodC@users.noreply.github.com>2026-06-22 07:28:04 -0400
committerLywx <kiritodev01@gmail.com>2026-06-29 18:48:27 -0600
commitb42a4fa16f7a75e2a96a7c107e6359c80b685863 (patch)
treeef37e07386cfadfc75bbe586038864e407730ac0
parentec17e8fe49b7bcd9b181e5d0feb05da660c6f061 (diff)
BK64: Add BB romhack support
-rw-r--r--lib/bk_zip/bk_unzip.cpp5
-rw-r--r--src/Companion.cpp14
-rw-r--r--src/factories/bk64/BKAssetFactory.cpp57
-rw-r--r--src/factories/bk64/ConfigFactory.cpp1113
-rw-r--r--src/factories/bk64/ConfigFactory.h87
-rw-r--r--src/factories/bk64/DialogFactory.cpp16
-rw-r--r--src/main.cpp18
7 files changed, 1306 insertions, 4 deletions
diff --git a/lib/bk_zip/bk_unzip.cpp b/lib/bk_zip/bk_unzip.cpp
index f276d07..e1c8ab1 100644
--- a/lib/bk_zip/bk_unzip.cpp
+++ b/lib/bk_zip/bk_unzip.cpp
@@ -89,7 +89,10 @@ uint8_t* bk_unzip(const uint8_t* in_buffer, uint32_t* size) {
// Check for successful decompression:
// - Z_STREAM_END: normal completion
// - Z_BUF_ERROR with no input remaining: input fully consumed
- bool success = (result == Z_STREAM_END) || (result == Z_BUF_ERROR && stream.avail_in == 0);
+ // - Z_BUF_ERROR with output complete: all expected bytes produced (BB-extended ROMs
+ // may have compressed sizes that include trailing padding from adjacent assets)
+ bool success = (result == Z_STREAM_END) || (result == Z_BUF_ERROR && stream.avail_in == 0) ||
+ (result == Z_BUF_ERROR && stream.total_out == expected_len);
// Clean up zlib stream
inflateEnd(&stream);
diff --git a/src/Companion.cpp b/src/Companion.cpp
index e8ca2c0..40899ec 100644
--- a/src/Companion.cpp
+++ b/src/Companion.cpp
@@ -98,6 +98,7 @@
#ifdef BK64_SUPPORT
#include "factories/bk64/AnimFactory.h"
#include "factories/bk64/BKAssetFactory.h"
+#include "factories/bk64/ConfigFactory.h"
#include "factories/bk64/DemoInputFactory.h"
#include "factories/bk64/DialogFactory.h"
#include "factories/bk64/GeoLayoutFactory.h"
@@ -1193,8 +1194,17 @@ void Companion::Process(std::atomic<size_t>& assetCount) {
this->gCartridge->Initialize();
if (!config[this->gCartridge->GetHash()]) {
- SPDLOG_ERROR("No config found for {}", this->gCartridge->GetHash());
- return;
+ bool synthesized = false;
+#ifdef BK64_SUPPORT
+ if (this->gRomPath.has_value()) {
+ synthesized = BK64::TrySynthesizeRomConfig(config, this->gCartridge->GetHash(), this->gRomPath.value(),
+ this->gRomData);
+ }
+#endif
+ if (!synthesized) {
+ SPDLOG_ERROR("No config found for {}", this->gCartridge->GetHash());
+ return;
+ }
}
this->gConfig.parseMode = ParseMode::Default;
diff --git a/src/factories/bk64/BKAssetFactory.cpp b/src/factories/bk64/BKAssetFactory.cpp
index 5aa1d8f..b109c17 100644
--- a/src/factories/bk64/BKAssetFactory.cpp
+++ b/src/factories/bk64/BKAssetFactory.cpp
@@ -1,7 +1,9 @@
#include "BKAssetFactory.h"
#include "Companion.h"
+#include "ConfigFactory.h"
#include "spdlog/spdlog.h"
#include "utils/Decompressor.h"
+#include "TinySHA1.hpp"
#include <cstring>
#include <iomanip>
#include <thread>
@@ -260,6 +262,13 @@ std::optional<std::shared_ptr<IParsedData>> BKAssetFactory::parse(std::vector<ui
const std::string langRegion =
Companion::Instance->GetConfig().dialogPack ? ResolveLangRegion() : std::string();
+ // Romhack o2rs overlay the base bk.o2r, whose manifest already lives there: walk the
+ // table for side effects (BB config, modified-slot registration) but don't output a
+ // partial manifest blob over it.
+ if (IsRomhack(buffer)) {
+ node["no_export"] = true;
+ }
+
reader.SetEndianness(Torch::Endianness::Big);
uint32_t assetCount = reader.ReadUInt32();
@@ -346,6 +355,10 @@ std::optional<std::shared_ptr<IParsedData>> BKAssetFactory::parse(std::vector<ui
t.join();
SPDLOG_INFO("Pre-decompression complete");
+ const auto& rom = Companion::Instance->GetRomData();
+ const bool romhackMode = IsRomhack(rom);
+ size_t skipHits = 0;
+ size_t skipMisses = 0;
size_t parseFailures = 0;
for (uint32_t i = 0; i < assetCount - 1; i++) {
@@ -383,6 +396,29 @@ std::optional<std::shared_ptr<IParsedData>> BKAssetFactory::parse(std::vector<ui
SPDLOG_TRACE("Parsing asset {} mode={} offset=0x{:X} size=0x{:X} comp={}", assetInfo.index, assetInfo.assetMode,
assetOffset, assetSize, assetInfo.compressionFlag);
+ if (romhackMode && assetSize > 0) {
+ const std::string& baselineHash = GetBaselineAssetHash(assetInfo.index);
+ if (!baselineHash.empty()) {
+ if (assetOffset + assetSize <= rom.size()) {
+ sha1::SHA1 s;
+ s.processBytes(rom.data() + assetOffset, assetSize);
+ uint32_t digest[5];
+ s.getDigest(digest);
+ char buf[41];
+ std::snprintf(buf, sizeof(buf), "%08x%08x%08x%08x%08x", digest[0], digest[1], digest[2], digest[3],
+ digest[4]);
+ if (baselineHash == buf) {
+ skipHits++;
+ if ((skipHits % 500) == 0) {
+ SPDLOG_WARN("Romhack hash-skip progress: {} hits, {} misses so far", skipHits, skipMisses);
+ }
+ continue;
+ }
+ skipMisses++;
+ }
+ }
+ }
+
switch (assetInfo.assetMode) {
case 0:
assetType = BKAssetType::Animation;
@@ -568,7 +604,10 @@ std::optional<std::shared_ptr<IParsedData>> BKAssetFactory::parse(std::vector<ui
}
}
- if (parseFailures > 0) {
+ if (romhackMode) {
+ SPDLOG_WARN("Romhack hash-skip: {} slots matched v1.0 baseline (skipped), {} differed (extracted), {} failed",
+ skipHits, skipMisses, parseFailures);
+ } else if (parseFailures > 0) {
SPDLOG_WARN("[BKAssetFactory] {} slot(s) failed to parse and were skipped", parseFailures);
}
@@ -649,6 +688,22 @@ std::optional<std::shared_ptr<IParsedData>> BKAssetFactory::parse(std::vector<ui
}
}
+ // Detect BB romhack configuration from extended ROMs and write binary blob.
+ // Derive a short name from the output path.
+ std::string romName;
+ {
+ std::string outPath = Companion::Instance->GetOutputPath();
+ auto slash = outPath.find_last_of("/\\");
+ std::string filename = (slash != std::string::npos) ? outPath.substr(slash + 1) : outPath;
+ auto dot = filename.rfind('.');
+ romName = (dot != std::string::npos) ? filename.substr(0, dot) : filename;
+ }
+ std::vector<char> configBlob = BuildGameConfigBlob(buffer, romName);
+ if (!configBlob.empty()) {
+ SPDLOG_INFO("BB romhack config detected ({} bytes), writing to o2r as '{}'", configBlob.size(), romName);
+ Companion::Instance->RegisterCompanionFile("aGameConfig", configBlob);
+ }
+
return std::make_shared<BKAssetData>(assetTableInfo, symbolMap);
}
diff --git a/src/factories/bk64/ConfigFactory.cpp b/src/factories/bk64/ConfigFactory.cpp
new file mode 100644
index 0000000..87e30a3
--- /dev/null
+++ b/src/factories/bk64/ConfigFactory.cpp
@@ -0,0 +1,1113 @@
+#include "ConfigFactory.h"
+#include "Companion.h"
+#include "TinySHA1.hpp"
+#include "binarytools/BinaryReader.h"
+#include "spdlog/spdlog.h"
+#include "utils/TorchUtils.h"
+#include <algorithm>
+#include <bk_zip/bk_unzip.h>
+#include <cstring>
+#include <fstream>
+#include <functional>
+#include <unordered_map>
+#include <yaml-cpp/yaml.h>
+
+namespace BK64 {
+
+// Helpers
+
+static uint16_t readBE16(const uint8_t* buf, uint32_t off) {
+ return (buf[off] << 8) | buf[off + 1];
+}
+
+static uint32_t readBE32(const uint8_t* buf, uint32_t off) {
+ return (buf[off] << 24) | (buf[off + 1] << 16) | (buf[off + 2] << 8) | buf[off + 3];
+}
+
+// Simple LE binary writer
+struct BlobWriter {
+ std::vector<char> data;
+
+ void writeU8(uint8_t v) {
+ data.push_back(static_cast<char>(v));
+ }
+ void writeU16(uint16_t v) {
+ writeU8(v & 0xFF);
+ writeU8((v >> 8) & 0xFF);
+ }
+ void writeU32(uint32_t v) {
+ writeU16(v & 0xFFFF);
+ writeU16((v >> 16) & 0xFFFF);
+ }
+ void writeS16(int16_t v) {
+ writeU16(static_cast<uint16_t>(v));
+ }
+
+ // Write section header, return index to patch entry_count later
+ size_t beginSection(ConfigSectionType type) {
+ writeU16(static_cast<uint16_t>(type));
+ size_t countPos = data.size();
+ writeU16(0); // placeholder
+ return countPos;
+ }
+
+ void patchU16(size_t pos, uint16_t val) {
+ data[pos] = static_cast<char>(val & 0xFF);
+ data[pos + 1] = static_cast<char>((val >> 8) & 0xFF);
+ }
+};
+
+// BB overlay constant definitions
+
+struct BBConfig {
+ uint32_t offset;
+ uint16_t vanilla;
+ bool isByte;
+};
+
+static const BBConfig sBBConfigs[] = {
+ { 254331, 0x85, true }, // NEW_GAME_MAP
+ { 624378, 0x01, true }, // START_LEVEL_1
+ { 625582, 0x69, true }, // START_LEVEL_2
+ { 59470, 0xC3A0, false }, // KNOW_ALL_MOVES
+ { 305126, 5, false }, // MUMBO_COST_TERMITE
+ { 305134, 10, false }, // MUMBO_COST_CROC
+ { 305142, 15, false }, // MUMBO_COST_WALRUS
+ { 305150, 20, false }, // MUMBO_COST_PUMPKIN
+ { 305158, 25, false }, // MUMBO_COST_BEE
+ { 782878, 100, false }, // EGGS_NORMAL_MAX
+ { 782910, 50, false }, // RED_FEATHERS_NORMAL_MAX
+ { 782938, 10, false }, // GOLD_FEATHERS_NORMAL_MAX
+ { 782870, 200, false }, // EGGS_CHEATO_MAX
+ { 782902, 100, false }, // RED_FEATHERS_CHEATO_MAX
+ { 782934, 20, false }, // GOLD_FEATHERS_CHEATO_MAX
+ { 19162, 100, false }, // NOTES_MAX
+ { 568414, 10, false }, // JIGGIES_PER_WORLD
+ { 568490, 2, false }, // HONEYCOMBS_PER_WORLD
+ { 568494, 6, false }, // EXTRA_HC_START
+ { 0x986FA, 0x0112, false }, // WARP_EXIT_BANJOS_HOUSE
+ { 0x98BAE, 0x6912, false }, // WARP_ENTER_LAIR
+ { 568479, 0x0B, true }, // SPECIAL_LEVEL (vanilla: Spiral Mountain = 0x0B)
+ { 570539, 0x0B, true }, // HIDE_JIGGIES_LEVEL (vanilla: Spiral Mountain = 0x0B)
+ { 571219, 0x06, true }, // HIDE_COLLECTIBLES_LEVEL (vanilla: Grunty's Lair = 0x06)
+};
+static_assert(sizeof(sBBConfigs) / sizeof(sBBConfigs[0]) == static_cast<int>(CodeConstantKey::COUNT));
+
+// Vanilla defaults for FCF698 data (lair overlay objects)
+
+// D_8039347C - note door thresholds (12 doors)
+static const uint16_t sVanillaNoteDoors[12] = { 50, 180, 260, 350, 450, 640, 765, 810, 828, 846, 864, 882 };
+
+// D_803947F8 - jiggy puzzle costs (11 puzzles), first byte of each 4-byte entry
+static const uint8_t sVanillaJiggyCosts[11] = { 1, 2, 5, 7, 8, 9, 10, 12, 15, 25, 4 };
+
+// Vanilla level names from D_8036C58C (pause menu display)
+static const char* sVanillaLevelNames[13] = { "GAME TOTAL", "SPIRAL MOUNTAIN", "GRUNTILDA'S LAIR",
+ "MUMBO'S MOUNTAIN", "TREASURE TROVE COVE", "CLANKER'S CAVERN",
+ "BUBBLEGLOOP SWAMP", "FREEZEEZY PEAK", "GOBI'S VALLEY",
+ "MAD MONSTER MANSION", "RUSTY BUCKET BAY", "CLICK CLOCK WOOD",
+ "STOP 'N' SWOP" };
+
+// Find patched overlay via EOR boot code pointers
+
+static uint8_t* FindPatchedOverlay(const std::vector<uint8_t>& rom, uint32_t& outSize, uint32_t& outRomAddr) {
+ struct EORPair {
+ uint32_t u;
+ uint32_t l;
+ };
+ static const EORPair eorPairs[] = {
+ { 0x107E, 0x1086 }, { 0x2872, 0x287A }, { 0x287E, 0x2886 }, { 0x288A, 0x2892 }, { 0x2896, 0x289A },
+ { 0x28EA, 0x28F2 }, { 0x28F6, 0x28FE }, { 0x2902, 0x290A }, { 0x290E, 0x2912 },
+ };
+
+ for (const auto& pair : eorPairs) {
+ uint16_t pLui = (rom[pair.u] << 8) | rom[pair.u + 1];
+ uint16_t pAddiu = (rom[pair.l] << 8) | rom[pair.l + 1];
+ uint32_t addr = (pLui << 16) + static_cast<int16_t>(pAddiu);
+
+ if (addr == 0 || addr >= rom.size() - 6)
+ continue;
+ if (rom[addr] != 0x11 || rom[addr + 1] != 0x72)
+ continue;
+
+ uint32_t expectedLen = (rom[addr + 2] << 24) | (rom[addr + 3] << 16) | (rom[addr + 4] << 8) | rom[addr + 5];
+ if (expectedLen < 800000 || expectedLen > 1000000)
+ continue;
+
+ try {
+ uint32_t sz = 0x100000;
+ uint8_t* result = bk_unzip(rom.data() + addr, &sz);
+ if (result && sz == expectedLen) {
+ outSize = sz;
+ outRomAddr = addr;
+ return result;
+ }
+ if (result)
+ free(result);
+ } catch (...) {}
+ }
+
+ outSize = 0;
+ outRomAddr = 0;
+ return nullptr;
+}
+
+// Find relocated F9CAE0
+
+static uint8_t* FindRelocatedF9CAE0(const std::vector<uint8_t>& rom, uint32_t f37RomAddr, uint32_t& outSize) {
+ for (uint32_t scan = f37RomAddr + 6; scan < f37RomAddr + 0x80000 && scan + 6 < rom.size(); scan++) {
+ if (rom[scan] != 0x11)
+ continue;
+ if (rom[scan + 1] != 0x72)
+ continue;
+
+ uint32_t expected = (rom[scan + 2] << 24) | (rom[scan + 3] << 16) | (rom[scan + 4] << 8) | rom[scan + 5];
+ if (expected < 80000 || expected > 100000)
+ continue;
+
+ try {
+ uint32_t sz = 0x100000;
+ uint8_t* result = bk_unzip(rom.data() + scan, &sz);
+ if (result && sz == expected) {
+ SPDLOG_INFO("[ConfigFactory] Found relocated F9CAE0 at ROM 0x{:X} ({} bytes)", scan, sz);
+ outSize = sz;
+ return result;
+ }
+ if (result)
+ free(result);
+ } catch (...) {}
+
+ break;
+ }
+
+ outSize = 0;
+ return nullptr;
+}
+
+static constexpr uint32_t FCF698_ROM_OFFSET = 0x3F5CDB0;
+static constexpr uint32_t FCF698_SIZE = 9888;
+static constexpr uint32_t FCF698_NOTE_DOORS_OFF = 1996;
+static constexpr uint32_t FCF698_JIGGY_PUZZLES_OFF = 6984;
+
+static int ScanWarpDest(const uint8_t* ovl, uint32_t funcOff, uint32_t ovlSize) {
+ static const uint8_t kEpilogue[16] = {
+ 0x8F, 0xBF, 0x00, 0x14, // LW $ra, 0x14($sp)
+ 0x27, 0xBD, 0x00, 0x18, // ADDIU $sp, $sp, 0x18
+ 0x03, 0xE0, 0x00, 0x08, // JR $ra
+ 0x00, 0x00, 0x00, 0x00 // NOP
+ };
+
+ if (funcOff + 200 > ovlSize) {
+ return -1;
+ }
+
+ const uint8_t* func = ovl + funcOff;
+ for (int i = 0; i + 20 <= 200; i += 4) {
+ if (func[i] == 0x24 && func[i + 1] == 0x05 && memcmp(func + i + 4, kEpilogue, 16) == 0) {
+ return (func[i + 2] << 8) | func[i + 3];
+ }
+ }
+ return -1;
+}
+
+// Custom-code blob detection
+//
+// Some hacks inject their own MIPS code at ROM 0x3F00000; the boot loader copies it up to
+// RAM 0x80780000 and the hack rewrites a few vanilla `jal`s to jump into it. Lighthouse
+// runs the decompiled C, not the original MIPS, so none of that survives at runtime. The
+// hack's scripted behavior just quietly never happens.
+//
+// We can at least spot it and warn, so users aren't left wondering why a hack feels
+// half-broken on the port. Both signals have to fire; either one alone false-positives on
+// stray data or BB's own overlay relocations:
+// (a) a believable MIPS prologue (`27BD FFxx`) in the first 0x100 bytes at 0x3F00000, and
+// (b) a handful of `jal 0x80780000+` self-calls inside the blob - real code calls itself
+// constantly, random data doesn't.
+
+static constexpr uint32_t CUSTOM_CODE_ROM_OFFSET = 0x3F00000;
+static constexpr uint32_t CUSTOM_CODE_RAM_BASE = 0x80700000;
+static constexpr uint32_t CUSTOM_CODE_RAM_END = 0x80800000;
+static constexpr uint32_t CUSTOM_CODE_SCAN_SIZE = 0x40000; // 256 KB scan window
+static constexpr int CUSTOM_CODE_MIN_INTERNAL_JALS = 4; // self-call threshold
+
+static bool LooksLikeMipsFunctionPrologue(const uint8_t* p) {
+ // addiu $sp, $sp, -N -> 0x27 0xBD 0xFF 0xxx
+ return p[0] == 0x27 && p[1] == 0xBD && p[2] == 0xFF;
+}
+
+// Count `jal` instructions in [start, start+size) whose target lies in
+// [CUSTOM_CODE_RAM_BASE, CUSTOM_CODE_RAM_END). Returns the count and, via
+// outFirstTarget, the first such target seen (for logging).
+static int CountInternalCustomCodeJals(const std::vector<uint8_t>& rom, uint32_t start, uint32_t size,
+ uint32_t& outFirstTarget) {
+ outFirstTarget = 0;
+ int count = 0;
+ uint32_t end = start + size;
+ if (end > rom.size())
+ end = static_cast<uint32_t>(rom.size());
+ for (uint32_t off = start; off + 4 <= end; off += 4) {
+ if (rom[off] != 0x0C)
+ continue; // op=3 (JAL) starts with 0x0C in BE
+ uint32_t word = (uint32_t(rom[off]) << 24) | (uint32_t(rom[off + 1]) << 16) | (uint32_t(rom[off + 2]) << 8) |
+ uint32_t(rom[off + 3]);
+ if ((word >> 26) != 0x03)
+ continue;
+ uint32_t target = 0x80000000u | ((word & 0x03FFFFFFu) << 2);
+ if (target >= CUSTOM_CODE_RAM_BASE && target < CUSTOM_CODE_RAM_END) {
+ if (count == 0)
+ outFirstTarget = target;
+ count++;
+ }
+ }
+ return count;
+}
+
+// In ROM, the custom-code blob is rzip-compressed (header `0x11 0x72`) and
+// gets decompressed to RAM somewhere in [0x80700000, 0x80800000) by a boot
+// stub. Scanning the raw ROM bytes won't find the jal-to-self pattern; we
+// have to decompress every rzip blob we find in the high-ROM region and
+// check the decompressed payload for internal jal self-references.
+//
+// On success, also writes the decompressed-payload SHA1 to outBlobSha1.
+// Hash is written to cleanly identify the particular blob so Lighthouse can
+// act on it.
+static bool DetectCustomCodeBlob(const std::vector<uint8_t>& rom, uint32_t& outBlobRomOffset, int& outInternalJalCount,
+ uint32_t& outFirstInternalTarget, uint8_t outBlobSha1[20]) {
+ outBlobRomOffset = 0;
+ outInternalJalCount = 0;
+ outFirstInternalTarget = 0;
+ if (outBlobSha1) {
+ std::memset(outBlobSha1, 0, 20);
+ }
+
+ if (rom.size() < CUSTOM_CODE_ROM_OFFSET) {
+ return false;
+ }
+
+ // Walk every rzip blob in the high-ROM region and check if
+ // its decompressed contents look like the custom-code blob.
+ for (uint32_t off = CUSTOM_CODE_ROM_OFFSET; off + 6 <= rom.size(); off += 4) {
+ if (rom[off] != 0x11 || rom[off + 1] != 0x72) {
+ continue;
+ }
+ uint32_t expectedSize = (uint32_t(rom[off + 2]) << 24) | (uint32_t(rom[off + 3]) << 16) |
+ (uint32_t(rom[off + 4]) << 8) | uint32_t(rom[off + 5]);
+ // Plausible size for an injected custom-code blob: ~10 KB to ~1 MB.
+ if (expectedSize < 0x4000 || expectedSize > 0x100000) {
+ continue;
+ }
+
+ uint8_t* unzipped = nullptr;
+ uint32_t unzippedSize = expectedSize;
+ try {
+ unzipped = bk_unzip(rom.data() + off, &unzippedSize);
+ } catch (...) {
+ continue;
+ }
+ if (!unzipped || unzippedSize != expectedSize) {
+ if (unzipped)
+ free(unzipped);
+ continue;
+ }
+
+ // Now apply the original two signals against the decompressed payload.
+ bool hasPrologue = false;
+ uint32_t prologueScan = std::min<uint32_t>(unzippedSize, 0x100);
+ for (uint32_t p = 0; p + 4 <= prologueScan; p += 4) {
+ if (LooksLikeMipsFunctionPrologue(unzipped + p)) {
+ hasPrologue = true;
+ break;
+ }
+ }
+ if (!hasPrologue) {
+ free(unzipped);
+ continue;
+ }
+
+ std::vector<uint8_t> payload(unzipped, unzipped + unzippedSize);
+ free(unzipped);
+ uint32_t firstTarget = 0;
+ int jals =
+ CountInternalCustomCodeJals(payload, 0, static_cast<uint32_t>(payload.size()), firstTarget);
+ if (jals < CUSTOM_CODE_MIN_INTERNAL_JALS) {
+ continue;
+ }
+
+ outBlobRomOffset = off;
+ outInternalJalCount = jals;
+ outFirstInternalTarget = firstTarget;
+ if (outBlobSha1) {
+ sha1::SHA1 s;
+ s.processBytes(payload.data(), payload.size());
+ uint32_t digest[5];
+ s.getDigest(digest);
+ for (int i = 0; i < 5; i++) {
+ outBlobSha1[i * 4 + 0] = (digest[i] >> 24) & 0xFF;
+ outBlobSha1[i * 4 + 1] = (digest[i] >> 16) & 0xFF;
+ outBlobSha1[i * 4 + 2] = (digest[i] >> 8) & 0xFF;
+ outBlobSha1[i * 4 + 3] = digest[i] & 0xFF;
+ }
+ }
+ return true;
+ }
+
+ return false;
+}
+
+// Public wrapper: thin bool-returning view used by Lighthouse to gate UI before
+// running the rest of extraction. Same detection logic as DetectCustomCodeBlob
+// above; we discard the returned metadata for callers that just want a yes/no.
+bool HasCustomCodeBlob(const std::vector<uint8_t>& rom) {
+ uint32_t blobRom = 0, firstTarget = 0;
+ int jalCount = 0;
+ return DetectCustomCodeBlob(rom, blobRom, jalCount, firstTarget, nullptr);
+}
+
+// Main entry point
+
+std::vector<char> BuildGameConfigBlob(const std::vector<uint8_t>& rom, const std::string& romName) {
+ // Only BB-extended ROMs
+ if (rom.size() <= 0x1000000) {
+ return {};
+ }
+
+ // Look for injected custom MIPS code.
+ uint32_t customCodeBlobRom = 0;
+ uint32_t customCodeFirstTarget = 0;
+ int customCodeJalCount = 0;
+ uint8_t customCodeSha1[20] = {};
+ bool hasCustomCode =
+ DetectCustomCodeBlob(rom, customCodeBlobRom, customCodeJalCount, customCodeFirstTarget, customCodeSha1);
+ if (hasCustomCode) {
+ char hex[41];
+ for (int i = 0; i < 20; i++) {
+ std::snprintf(hex + i * 2, 3, "%02x", customCodeSha1[i]);
+ }
+ SPDLOG_WARN("[ConfigFactory] Custom MIPS code detected at ROM 0x{:X}; {} internal jal self-references "
+ "seen, first target 0x{:08X}; sha1={}.",
+ customCodeBlobRom, customCodeJalCount, customCodeFirstTarget, hex);
+ SPDLOG_WARN("[ConfigFactory] This romhack ships custom code. Lighthouse runs the decompiled C "
+ "source and cannot execute injected code, so scripted behavior driven by that code "
+ "will be silently absent until a hand-port is implemented.");
+ }
+
+ // Find patched overlay
+ uint32_t overlaySize = 0, f37RomAddr = 0;
+ uint8_t* overlay = FindPatchedOverlay(rom, overlaySize, f37RomAddr);
+ if (!overlay)
+ return {};
+
+ SPDLOG_INFO("[ConfigFactory] Found patched overlay at ROM 0x{:X} ({} bytes)", f37RomAddr, overlaySize);
+
+ BlobWriter blob;
+ uint16_t sectionCount = 0;
+
+ // Write header (magic + version + placeholder section count + romName)
+ blob.writeU32(GAMECONFIG_MAGIC);
+ blob.writeU16(GAMECONFIG_VERSION);
+ size_t sectionCountPos = blob.data.size();
+ blob.writeU16(0); // placeholder
+
+ // Write rom name (u8 len + chars, no null terminator)
+ uint8_t nameLen = static_cast<uint8_t>(std::min(romName.size(), size_t(255)));
+ blob.writeU8(nameLen);
+ for (uint8_t i = 0; i < nameLen; i++) {
+ blob.writeU8(static_cast<uint8_t>(romName[i]));
+ }
+
+ // Section: CUSTOM_CODE
+ if (hasCustomCode) {
+ // entry_count is informational here; the section always carries a single
+ // {ramBase, sha1[20]} payload. Use 1 so anyone walking entries lands sanely.
+ size_t secPos = blob.beginSection(ConfigSectionType::CUSTOM_CODE);
+ blob.writeU32(CUSTOM_CODE_RAM_BASE);
+ for (int i = 0; i < 20; i++) {
+ blob.writeU8(customCodeSha1[i]);
+ }
+ blob.patchU16(secPos, 1);
+ sectionCount++;
+ }
+
+ // Section: ROM_HASH (data-only romhack identifier)
+ {
+ std::string romHash = Companion::Instance->GetCartridge()->GetHash();
+ // GetHash() returns 40 lowercase hex chars; convert back to 20 raw bytes.
+ if (romHash.size() == 40) {
+ size_t secPos = blob.beginSection(ConfigSectionType::ROM_HASH);
+ for (int i = 0; i < 20; i++) {
+ uint8_t byte = 0;
+ for (int n = 0; n < 2; n++) {
+ char c = romHash[i * 2 + n];
+ uint8_t nib = (c >= '0' && c <= '9') ? c - '0'
+ : (c >= 'a' && c <= 'f') ? c - 'a' + 10
+ : (c >= 'A' && c <= 'F') ? c - 'A' + 10
+ : 0;
+ byte = (byte << 4) | nib;
+ }
+ blob.writeU8(byte);
+ }
+ blob.patchU16(secPos, 1);
+ sectionCount++;
+ SPDLOG_INFO("[ConfigFactory] ROM_HASH = {}", romHash);
+ } else {
+ SPDLOG_WARN("[ConfigFactory] ROM hash unexpected length {}, skipping ROM_HASH section",
+ romHash.size());
+ }
+ }
+
+ // Section: CODE_CONSTANTS
+ {
+ size_t secPos = blob.beginSection(ConfigSectionType::CODE_CONSTANTS);
+ uint16_t count = 0;
+
+ for (int i = 0; i < static_cast<int>(CodeConstantKey::COUNT); i++) {
+ const auto& entry = sBBConfigs[i];
+ if (entry.offset + 2 > overlaySize)
+ continue;
+
+ uint16_t val = entry.isByte ? overlay[entry.offset] : readBE16(overlay, entry.offset);
+ if (val != entry.vanilla) {
+ blob.writeU16(static_cast<uint16_t>(i));
+ blob.writeU16(val);
+ count++;
+ SPDLOG_INFO("[ConfigFactory] code_const[{}] = {} (vanilla: {})", i, val, entry.vanilla);
+ }
+ }
+
+ if (count > 0) {
+ blob.patchU16(secPos, count);
+ sectionCount++;
+ } else {
+ blob.data.resize(secPos - 2); // remove empty section header
+ }
+ }
+
+ // F9CAE0 sections
+ if (f37RomAddr > 0) {
+ uint32_t modSize = 0, vanSize = 0x100000;
+ uint8_t* modF9 = FindRelocatedF9CAE0(rom, f37RomAddr, modSize);
+
+ // Vanilla F9CAE0 at original location
+ uint8_t* vanF9 = nullptr;
+ static constexpr uint32_t VANILLA_F9CAE0 = 0xF9CAE0;
+ if (VANILLA_F9CAE0 + 6 < rom.size() && rom[VANILLA_F9CAE0] == 0x11 && rom[VANILLA_F9CAE0 + 1] == 0x72) {
+ try {
+ vanF9 = bk_unzip(rom.data() + VANILLA_F9CAE0, &vanSize);
+ } catch (...) {}
+ }
+
+ if (modF9 && vanF9 && modSize == vanSize) {
+ uint32_t f9Size = modSize;
+
+ // Section 2: SCENE_REMAP (D_8036B810 at offset 0x8288)
+ if (f9Size > 0x8288 + 155 * 8) {
+ size_t secPos = blob.beginSection(ConfigSectionType::SCENE_REMAP);
+ uint16_t count = 0;
+
+ for (uint32_t off = 0x8288; off + 4 <= f9Size; off += 8) {
+ uint16_t mapId = readBE16(modF9, off);
+ if (mapId == 0 && off > 0x8288 + 8)
+ break;
+ if (mapId == 0)
+ continue;
+
+ uint16_t modLevel = readBE16(modF9, off + 2);
+ uint16_t vanLevel = readBE16(vanF9, off + 2);
+ if (modLevel != vanLevel) {
+ blob.writeU16(mapId);
+ blob.writeU16(modLevel);
+ count++;
+ SPDLOG_INFO("[ConfigFactory] scene_remap: map 0x{:X} level {} -> {}", mapId, vanLevel,
+ modLevel);
+ }
+ }
+
+ if (count > 0) {
+ blob.patchU16(secPos, count);
+ sectionCount++;
+ } else {
+ blob.data.resize(secPos - 2);
+ }
+ }
+
+ // Section 3: RETURN_TO_LAIR (D_8036C560 at offset 0x8FD0)
+ if (f9Size > 0x8FD0 + 11 * 4) {
+ size_t secPos = blob.beginSection(ConfigSectionType::RETURN_TO_LAIR);
+ uint16_t count = 0;
+
+ for (int i = 0; i < 11; i++) {
+ uint32_t off = 0x8FD0 + i * 4;
+ uint16_t modMap = readBE16(modF9, off);
+ uint16_t modExit = readBE16(modF9, off + 2);
+ uint16_t vanMap = readBE16(vanF9, off);
+ uint16_t vanExit = readBE16(vanF9, off + 2);
+ if (modMap != vanMap || modExit != vanExit) {
+ blob.writeU8(static_cast<uint8_t>(i));
+ blob.writeU8(0); // pad
+ blob.writeU16(modMap);
+ blob.writeU16(modExit);
+ count++;
+ SPDLOG_INFO("[ConfigFactory] return_to_lair[{}]: 0x{:X}/0x{:X} -> "
+ "0x{:X}/0x{:X}",
+ i, vanMap, vanExit, modMap, modExit);
+ }
+ }
+
+ if (count > 0) {
+ blob.patchU16(secPos, count);
+ sectionCount++;
+ } else {
+ blob.data.resize(secPos - 2);
+ }
+ }
+
+ // Section 4: MUSIC (offset 0xA8F0)
+ if (f9Size > 0xA8F0 + 8) {
+ size_t secPos = blob.beginSection(ConfigSectionType::MUSIC);
+ uint16_t count = 0;
+
+ for (uint32_t off = 0xA8F0; off + 8 <= f9Size; off += 8) {
+ uint16_t mapId = readBE16(modF9, off);
+ if (mapId == 0 && off > 0xA8F0 + 8)
+ break;
+ if (mapId == 0)
+ continue;
+
+ bool differs = memcmp(modF9 + off, vanF9 + off, 8) != 0;
+ if (differs) {
+ blob.writeU16(mapId);
+ blob.writeU16(readBE16(modF9, off + 2));
+ blob.writeU16(readBE16(modF9, off + 4));
+ count++;
+ }
+ }
+
+ if (count > 0) {
+ blob.patchU16(secPos, count);
+ sectionCount++;
+ } else {
+ blob.data.resize(secPos - 2);
+ }
+ }
+
+ // Section 5: SKYBOX (offset 0x87B0, 40-byte entries)
+ // Full 3-layer extraction: scene_id + 3x {model_id, scale(f32 as LE u32),
+ // rotation(f32 as LE u32)} MapSkyInfo layout in F9CAE0 (MIPS BE, 40
+ // bytes):
+ // +0: s16 map (BE)
+ // +4: s16 model1 (BE), +8: f32 scale1 (BE), +12: f32 rot1 (BE)
+ // +16: s16 model2 (BE), +20: f32 scale2 (BE), +24: f32 rot2 (BE)
+ // +28: s16 model3 (BE), +32: f32 scale3 (BE), +36: f32 rot3 (BE)
+ if (f9Size > 0x87B0 + 40) {
+ size_t secPos = blob.beginSection(ConfigSectionType::SKYBOX);
+ uint16_t count = 0;
+
+ for (uint32_t off = 0x87B0; off + 40 <= f9Size; off += 40) {
+ uint16_t sceneId = readBE16(modF9, off);
+ if (sceneId == 0 && off > 0x87B0 + 40)
+ break;
+ if (sceneId == 0)
+ continue;
+
+ bool differs = memcmp(modF9 + off, vanF9 + off, 40) != 0;
+ if (differs) {
+ blob.writeU16(sceneId);
+ // 3 layers: model (s16 LE), scale (f32 as u32 LE), rotation (f32 as
+ // u32 LE)
+ for (int layer = 0; layer < 3; layer++) {
+ uint32_t base = off + 4 + layer * 12;
+ blob.writeS16(static_cast<int16_t>(readBE16(modF9, base))); // model_id
+ blob.writeU32(readBE32(modF9, base + 4)); // scale (BE f32 bits -> LE u32)
+ blob.writeU32(readBE32(modF9, base + 8)); // rotation (BE f32 bits -> LE u32)
+ }
+ count++;
+ SPDLOG_INFO("[ConfigFactory] skybox: scene 0x{:X} models [{},{},{}]", sceneId,
+ readBE16(modF9, off + 4), readBE16(modF9, off + 16), readBE16(modF9, off + 28));
+ }
+ }
+
+ if (count > 0) {
+ blob.patchU16(secPos, count);
+ sectionCount++;
+ } else {
+ blob.data.resize(secPos - 2);
+ }
+ }
+
+ // Section 6: SCENE_DEF (offset 0x7650, 24-byte MIPS entries)
+ // MapModelDescription: {s16 map, s16 opa, s16 xlu, s16 min[3], s16
+ // max[3], pad[2], f32 scale}
+ if (f9Size > 0x7650 + 24) {
+ size_t secPos = blob.beginSection(ConfigSectionType::SCENE_DEF);
+ uint16_t count = 0;
+
+ for (uint32_t off = 0x7650; off + 24 <= f9Size; off += 24) {
+ uint16_t sceneId = readBE16(modF9, off);
+ if (sceneId == 0 && off > 0x7650 + 24)
+ break;
+ if (sceneId == 0)
+ continue;
+
+ bool differs = memcmp(modF9 + off, vanF9 + off, 24) != 0;
+ if (differs) {
+ blob.writeU16(sceneId);
+ blob.writeS16(static_cast<int16_t>(readBE16(modF9, off + 2))); // opa
+ blob.writeS16(static_cast<int16_t>(readBE16(modF9, off + 4))); // xlu
+ for (int b = 0; b < 3; b++) {
+ blob.writeS16(static_cast<int16_t>(readBE16(modF9, off + 6 + b * 2))); // min[3]
+ }
+ for (int b = 0; b < 3; b++) {
+ blob.writeS16(static_cast<int16_t>(readBE16(modF9, off + 12 + b * 2))); // max[3]
+ }
+ blob.writeU32(readBE32(modF9, off + 20)); // scale (BE f32 bits -> LE u32)
+ count++;
+ }
+ }
+
+ if (count > 0) {
+ blob.patchU16(secPos, count);
+ sectionCount++;
+ } else {
+ blob.data.resize(secPos - 2);
+ }
+ }
+
+ // Section 9: LEVEL_NAMES (F9CAE0 offset 86068-86302, null-terminated
+ // ASCII)
+ if (f9Size > 86302) {
+ size_t secPos = blob.beginSection(ConfigSectionType::LEVEL_NAMES);
+ uint16_t count = 0;
+
+ // Parse the same way BB does: scan for null-terminated strings
+ int nameIdx = 0;
+ uint32_t nameStart = 0;
+ for (uint32_t off = 86068; off < 86302 && nameIdx < 13; off++) {
+ if (modF9[off] == 0) {
+ if (nameStart > 0) {
+ // Compare against vanilla
+ uint32_t len = off - nameStart;
+ std::string modName(reinterpret_cast<char*>(modF9 + nameStart), len);
+ bool differs = (nameIdx >= 13) || (modName != sVanillaLevelNames[nameIdx]);
+ if (differs) {
+ blob.writeU8(static_cast<uint8_t>(nameIdx));
+ blob.writeU8(static_cast<uint8_t>(len));
+ for (uint32_t c = 0; c < len; c++) {
+ blob.writeU8(modF9[nameStart + c]);
+ }
+ count++;
+ SPDLOG_INFO("[ConfigFactory] level_name[{}] = \"{}\"", nameIdx, modName);
+ }
+ nameIdx++;
+ nameStart = 0;
+ }
+ } else {
+ if (nameStart == 0) {
+ nameStart = off;
+ }
+ }
+ }
+
+ if (count > 0) {
+ blob.patchU16(secPos, count);
+ sectionCount++;
+ } else {
+ blob.data.resize(secPos - 2);
+ }
+ }
+ // Section 10: WARP_DESTINATIONS
+ // BB stores a warp function pointer table in F9CAE0 at offset 0xC3F0
+ // (558 entries, 4 bytes each). Each points to a warp function in the
+ // F37F90 code overlay. Decompress the vanilla overlay from its
+ // original ROM location, scan both copies for the ADDIU $a1 dest
+ // pattern, and emit diffs.
+ {
+ static constexpr uint32_t VANILLA_F37F90_OFFSET = 0xF37F90;
+ static constexpr uint32_t WARP_TABLE_OFF = 0xC3F0; // 50160 decimal
+ static constexpr int WARP_ENTRY_COUNT = 558; // (52392 - 50160) / 4
+ // BB base: 0x80000000 + 2650000 (Form1.cs line 2538)
+ static constexpr uint32_t N64_OVL_BASE = 0x80000000U + 2650000U;
+
+ uint8_t* vanOvl = nullptr;
+ uint32_t vanOvlSize = 0x100000;
+ if (VANILLA_F37F90_OFFSET + 6 < rom.size() && rom[VANILLA_F37F90_OFFSET] == 0x11 &&
+ rom[VANILLA_F37F90_OFFSET + 1] == 0x72) {
+ try {
+ vanOvl = bk_unzip(rom.data() + VANILLA_F37F90_OFFSET, &vanOvlSize);
+ } catch (...) {}
+ }
+
+ if (vanOvl && f9Size > WARP_TABLE_OFF + WARP_ENTRY_COUNT * 4) {
+ size_t secPos = blob.beginSection(ConfigSectionType::WARP_DESTINATIONS);
+ uint16_t count = 0;
+
+ for (int w = 0; w < WARP_ENTRY_COUNT; w++) {
+ uint32_t tOff = WARP_TABLE_OFF + w * 4;
+ uint32_t addr = readBE32(vanF9, tOff);
+ if (addr < N64_OVL_BASE) {
+ continue;
+ }
+ uint32_t funcOff = addr - N64_OVL_BASE;
+
+ int vanDest = ScanWarpDest(vanOvl, funcOff, vanOvlSize);
+ int modDest = ScanWarpDest(overlay, funcOff, overlaySize);
+
+ if (vanDest >= 0 && modDest >= 0 && vanDest != modDest) {
+ blob.writeU16(static_cast<uint16_t>(w));
+ blob.writeU16(static_cast<uint16_t>(modDest));
+ count++;
+ SPDLOG_INFO("[ConfigFactory] warp[{}]: 0x{:04X} -> 0x{:04X}", w, vanDest, modDest);
+ }
+ }
+
+ if (count > 0) {
+ blob.patchU16(secPos, count);
+ sectionCount++;
+ } else {
+ blob.data.resize(secPos - 2);
+ }
+ } else if (!vanOvl) {
+ SPDLOG_WARN("[ConfigFactory] Could not decompress vanilla F37F90 overlay for warp diff");
+ }
+
+ if (vanOvl) {
+ free(vanOvl);
+ }
+ }
+ } else if (modF9 && vanF9) {
+ SPDLOG_WARN("[ConfigFactory] F9CAE0 size mismatch: modified={} vanilla={}", modSize, vanSize);
+ }
+
+ if (modF9)
+ free(modF9);
+ if (vanF9)
+ free(vanF9);
+ }
+
+ free(overlay);
+
+ // FCF698 sections (lair overlay objects, uncompressed in globalized blob)
+ if (rom.size() > FCF698_ROM_OFFSET + FCF698_SIZE) {
+ const uint8_t* fcf = rom.data() + FCF698_ROM_OFFSET;
+
+ // Validate: first note door value should be a reasonable u16 (0-10000)
+ uint16_t firstDoor = readBE16(fcf, FCF698_NOTE_DOORS_OFF);
+ if (firstDoor <= 10000) {
+ // Section 7: NOTE_DOORS
+ {
+ size_t secPos = blob.beginSection(ConfigSectionType::NOTE_DOORS);
+ uint16_t count = 0;
+
+ for (int i = 0; i < 12; i++) {
+ uint16_t val = readBE16(fcf, FCF698_NOTE_DOORS_OFF + i * 2);
+ if (val != sVanillaNoteDoors[i]) {
+ blob.writeU8(static_cast<uint8_t>(i));
+ blob.writeU8(0); // pad
+ blob.writeU16(val);
+ count++;
+ SPDLOG_INFO("[ConfigFactory] note_door[{}] = {} (vanilla: {})", i, val, sVanillaNoteDoors[i]);
+ }
+ }
+
+ if (count > 0) {
+ blob.patchU16(secPos, count);
+ sectionCount++;
+ } else {
+ blob.data.resize(secPos - 2);
+ }
+ }
+
+ // Section 8: JIGGY_PUZZLES
+ {
+ size_t secPos = blob.beginSection(ConfigSectionType::JIGGY_PUZZLES);
+ uint16_t count = 0;
+
+ for (int i = 0; i < 11; i++) {
+ uint8_t cost = fcf[FCF698_JIGGY_PUZZLES_OFF + i * 4];
+ if (cost != sVanillaJiggyCosts[i]) {
+ blob.writeU8(static_cast<uint8_t>(i));
+ blob.writeU8(cost);
+ count++;
+ SPDLOG_INFO("[ConfigFactory] jiggy_puzzle[{}] = {} (vanilla: {})", i, cost,
+ sVanillaJiggyCosts[i]);
+ }
+ }
+
+ if (count > 0) {
+ blob.patchU16(secPos, count);
+ sectionCount++;
+ } else {
+ blob.data.resize(secPos - 2);
+ }
+ }
+ } else {
+ SPDLOG_WARN("[ConfigFactory] FCF698 validation failed: first door value "
+ "{} out of range",
+ firstDoor);
+ }
+ }
+
+ // Patch section count
+ blob.patchU16(sectionCountPos, sectionCount);
+
+ if (sectionCount == 0) {
+ return {};
+ }
+
+ SPDLOG_INFO("[ConfigFactory] Built aGameConfig blob: {} bytes, {} sections", blob.data.size(), sectionCount);
+ return blob.data;
+}
+
+// Asset-table hashing
+//
+// Lighthouse ships a per-slot SHA-1 of the v1.0 baseline at assets/yaml/<region>/<rev>/hashes.yaml.
+// When extracting a BB romhack, we hash each slot's compressed bytes: a match means the slot is bit-for-bit
+// vanilla, so skip the factory entirely.
+
+// Walk the BK64 asset table at ROM 0x5E90 and invoke `onSlot(index, sha1Hex)` for every non-empty,
+// non-zero-sized slot. Returns the total slot count (including empty slots), or 0 on parse failure.
+static size_t WalkAssetTable(const std::vector<uint8_t>& rom,
+ const std::function<void(uint32_t, const std::string&)>& onSlot) {
+ if (rom.size() < 0x6000) {
+ SPDLOG_ERROR("ROM too small to contain BK64 asset table");
+ return 0;
+ }
+
+ constexpr uint32_t kAssetTableOffset = 0x5E90;
+
+ LUS::BinaryReader reader(reinterpret_cast<char*>(const_cast<uint8_t*>(rom.data() + kAssetTableOffset)),
+ rom.size() - kAssetTableOffset);
+ reader.SetEndianness(Torch::Endianness::Big);
+ const uint32_t assetCount = reader.ReadUInt32();
+ reader.ReadUInt32();
+
+ if (assetCount == 0 || assetCount > 0x10000) {
+ SPDLOG_ERROR("Implausible asset count {} at 0x{:X}", assetCount, kAssetTableOffset);
+ return 0;
+ }
+
+ const uint32_t dataStart = kAssetTableOffset + 8 + assetCount * 8;
+ if (dataStart >= rom.size()) {
+ SPDLOG_ERROR("Asset table extends past ROM end");
+ return 0;
+ }
+
+ struct Entry {
+ uint32_t blobOffset;
+ int16_t compressionFlag;
+ int16_t tFlag;
+ };
+ std::vector<Entry> entries(assetCount);
+ for (uint32_t i = 0; i < assetCount; i++) {
+ entries[i].blobOffset = reader.ReadUInt32();
+ entries[i].compressionFlag = reader.ReadInt16();
+ entries[i].tFlag = reader.ReadInt16();
+ }
+
+ for (uint32_t i = 0; i < assetCount; i++) {
+ const auto& e = entries[i];
+ if (e.tFlag == 4) {
+ continue;
+ }
+
+ uint32_t endRel = 0;
+ bool haveEnd = false;
+ for (uint32_t j = i + 1; j < assetCount; j++) {
+ if (entries[j].blobOffset != e.blobOffset) {
+ endRel = entries[j].blobOffset;
+ haveEnd = true;
+ break;
+ }
+ }
+ if (!haveEnd) {
+ endRel = static_cast<uint32_t>(rom.size() - dataStart);
+ }
+ if (endRel <= e.blobOffset) {
+ continue;
+ }
+ const uint32_t absOff = dataStart + e.blobOffset;
+ const uint32_t size = endRel - e.blobOffset;
+ if (absOff + size > rom.size()) {
+ continue;
+ }
+
+ sha1::SHA1 s;
+ s.processBytes(rom.data() + absOff, size);
+ uint32_t digest[5];
+ s.getDigest(digest);
+ char buf[41];
+ std::snprintf(buf, sizeof(buf), "%08x%08x%08x%08x%08x", digest[0], digest[1], digest[2], digest[3], digest[4]);
+
+ onSlot(i, std::string(buf));
+ }
+
+ return assetCount;
+}
+
+void EmitAssetHashes(const std::filesystem::path& romPath, const std::filesystem::path& outputPath) {
+ std::ifstream input(romPath, std::ios::binary);
+ if (!input) {
+ SPDLOG_ERROR("Failed to open ROM at {}", romPath.string());
+ return;
+ }
+ std::vector<uint8_t> rom((std::istreambuf_iterator<char>(input)), {});
+ input.close();
+
+ std::ofstream out(outputPath);
+ if (!out) {
+ SPDLOG_ERROR("Failed to open output {}", outputPath.string());
+ return;
+ }
+ out << "# Auto-generated by `torch hashes`. Per-slot SHA-1 of the compressed\n"
+ << "# byte range that the BK64 asset table at 0x5E90 points to. Used by\n"
+ << "# Torch to skip factory dispatch for unmodified slots when extracting\n"
+ << "# BB-style romhacks. DO NOT hand-edit.\n"
+ << "hashes:\n";
+
+ size_t hashed = 0;
+ const size_t total = WalkAssetTable(rom, [&](uint32_t i, const std::string& sha1) {
+ out << " 0x" << std::hex << std::uppercase << i << std::dec << ": " << sha1 << "\n";
+ hashed++;
+ });
+ out.close();
+
+ SPDLOG_INFO("Hashed {} of {} slots ({} empty/zero-sized) -> {}", hashed, total, total - hashed,
+ outputPath.string());
+}
+
+size_t CountModifiedSlots(const std::vector<uint8_t>& rom, const std::filesystem::path& hashesYamlPath) {
+ namespace fs = std::filesystem;
+ if (!fs::exists(hashesYamlPath)) {
+ SPDLOG_WARN("hashes.yaml not found at {}; cannot count modified slots", hashesYamlPath.string());
+ return 0;
+ }
+ std::unordered_map<uint32_t, std::string> baseline;
+ try {
+ YAML::Node root = YAML::LoadFile(hashesYamlPath.string());
+ YAML::Node hashes = root["hashes"];
+ if (hashes && hashes.IsMap()) {
+ for (auto it = hashes.begin(); it != hashes.end(); ++it) {
+ baseline[it->first.as<uint32_t>()] = it->second.as<std::string>();
+ }
+ }
+ } catch (const std::exception& e) {
+ SPDLOG_WARN("Failed to parse hashes.yaml: {}", e.what());
+ return 0;
+ }
+
+ size_t modified = 0;
+ WalkAssetTable(rom, [&](uint32_t i, const std::string& sha1) {
+ auto it = baseline.find(i);
+ if (it == baseline.end() || it->second != sha1) {
+ modified++;
+ }
+ });
+ return modified;
+}
+
+namespace {
+struct BaselineHashCache {
+ std::unordered_map<uint32_t, std::string> entries;
+ bool attempted = false;
+};
+BaselineHashCache& GetBaselineCache() {
+ static BaselineHashCache cache;
+ return cache;
+}
+} // namespace
+
+const std::string& GetBaselineAssetHash(uint32_t assetId) {
+ namespace fs = std::filesystem;
+ static const std::string kEmpty;
+
+ auto& cache = GetBaselineCache();
+ if (!cache.attempted) {
+ cache.attempted = true;
+ const auto path = fs::path(Companion::Instance->GetAssetPath()) / "hashes.yaml";
+ if (fs::exists(path)) {
+ try {
+ YAML::Node root = YAML::LoadFile(path.string());
+ YAML::Node hashes = root["hashes"];
+ if (hashes && hashes.IsMap()) {
+ for (auto it = hashes.begin(); it != hashes.end(); ++it) {
+ cache.entries[it->first.as<uint32_t>()] = it->second.as<std::string>();
+ }
+ SPDLOG_WARN("Loaded {} baseline asset hashes from {}", cache.entries.size(), path.string());
+ }
+ } catch (const std::exception& e) {
+ SPDLOG_WARN("Failed to load baseline hashes at {}: {}", path.string(), e.what());
+ }
+ }
+ }
+
+ auto it = cache.entries.find(assetId);
+ return (it != cache.entries.end()) ? it->second : kEmpty;
+}
+
+bool TrySynthesizeRomConfig(YAML::Node& config, const std::string& cartHash, const std::filesystem::path& romPath,
+ const std::vector<uint8_t>& rom) {
+ if (config[cartHash]) {
+ return false;
+ }
+ if (!IsRomhack(rom)) {
+ return false;
+ }
+
+ // The size check above is just the cheap signal. Confirm it's really a BB overlay:
+ // FindPatchedOverlay walks the BK-bootstrap EOR instruction pairs and checks for a
+ // BKZIP overlay at the encoded address. A 64MB ROM that isn't BB (say, a hack of some
+ // other N64 game) won't match the boot pattern, so we bail instead of running a BK64
+ // extraction that was never going to work.
+ uint32_t ovlSize = 0;
+ uint32_t ovlAddr = 0;
+ uint8_t* ovl = FindPatchedOverlay(rom, ovlSize, ovlAddr);
+ if (ovl == nullptr) {
+ SPDLOG_WARN("[ConfigFactory] {} is >16MB but doesn't look like a BB romhack. Aborting extraction.",
+ cartHash);
+ return false;
+ }
+ free(ovl); // we only needed the validation, not the bytes
+
+ std::string stem = romPath.stem().string();
+ std::string slug;
+ slug.reserve(stem.size());
+ bool lastWasDash = true; // suppress leading dash
+ for (char c : stem) {
+ if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) {
+ slug += c;
+ lastWasDash = false;
+ } else if (c >= 'A' && c <= 'Z') {
+ slug += static_cast<char>(c - 'A' + 'a');
+ lastWasDash = false;
+ } else if (!lastWasDash) {
+ slug += '-';
+ lastWasDash = true;
+ }
+ }
+ while (!slug.empty() && slug.back() == '-')
+ slug.pop_back();
+ if (slug.empty())
+ slug = cartHash.substr(0, 8);
+
+ YAML::Node entry;
+ entry["name"] = "BB Romhack (auto-detected: " + stem + ")";
+ entry["path"] = "assets/yaml/us/rev0";
+ YAML::Node entryCfg;
+ entryCfg["gbi"] = "F3DEX_BK64";
+ entryCfg["sort"] = "OFFSET";
+ entryCfg["logging"] = "WARN";
+ entryCfg["textures"] = "ADDITIONAL_DEFINES";
+ entryCfg["include_autogen"] = true;
+ YAML::Node entryOut;
+ entryOut["binary"] = "mods/~romhacks/" + slug + ".o2r";
+ entryOut["code"] = "src/assets";
+ entryOut["headers"] = "include/assets";
+ entryOut["modding"] = "src/assets";
+ entryCfg["output"] = entryOut;
+ entry["config"] = entryCfg;
+ config[cartHash] = entry;
+
+ SPDLOG_INFO("[ConfigFactory] Auto-detected BB romhack {}; routing to mods/~romhacks/{}.o2r", cartHash, slug);
+ return true;
+}
+
+} // namespace BK64
diff --git a/src/factories/bk64/ConfigFactory.h b/src/factories/bk64/ConfigFactory.h
new file mode 100644
index 0000000..59dc87f
--- /dev/null
+++ b/src/factories/bk64/ConfigFactory.h
@@ -0,0 +1,87 @@
+#pragma once
+
+#include <cstdint>
+#include <filesystem>
+#include <string>
+#include <vector>
+#include <yaml-cpp/yaml.h>
+
+namespace BK64 {
+
+// Vanilla BK is exactly 16MB. Anything bigger has been through Banjo's Backpack.
+inline bool IsRomhack(const std::vector<uint8_t>& rom) {
+ return rom.size() > 0x1000000;
+}
+
+// When a romhack, generate a config for it based on us v1.0.
+bool TrySynthesizeRomConfig(YAML::Node& config, const std::string& cartHash, const std::filesystem::path& romPath,
+ const std::vector<uint8_t>& rom);
+
+// Maintainer tool, creates a hashes.yml.
+void EmitAssetHashes(const std::filesystem::path& romPath, const std::filesystem::path& outputPath);
+
+// How many slots differ from the baseline hashes - a missing baseline entry counts as
+// changed. The port-side extractor uses it to size its progress bar to the real romhack
+// delta instead of every vanilla slot.
+size_t CountModifiedSlots(const std::vector<uint8_t>& rom, const std::filesystem::path& hashesYamlPath);
+
+// Baseline SHA-1 for one slot, or empty if there's no hashes.yaml or no entry for it.
+// Loaded lazily from gAssetPath/hashes.yaml on the first call, then held for the run.
+const std::string& GetBaselineAssetHash(uint32_t assetId);
+
+// Does the hack ship its own MIPS code?
+bool HasCustomCodeBlob(const std::vector<uint8_t>& rom);
+
+// Binary aGameConfig format: 'BKCF' header + typed sections
+
+constexpr uint32_t GAMECONFIG_MAGIC = 0x46434B42;
+constexpr uint16_t GAMECONFIG_VERSION = 1;
+
+enum class ConfigSectionType : uint16_t {
+ CODE_CONSTANTS = 1,
+ SCENE_REMAP = 2,
+ RETURN_TO_LAIR = 3,
+ MUSIC = 4,
+ SKYBOX = 5,
+ SCENE_DEF = 6,
+ NOTE_DOORS = 7,
+ JIGGY_PUZZLES = 8,
+ LEVEL_NAMES = 9,
+ WARP_DESTINATIONS = 10,
+ CUSTOM_CODE = 11,
+ ROM_HASH = 12,
+};
+
+// CODE_CONSTANTS key IDs - they index into the sBBConfigs table.
+enum class CodeConstantKey : uint16_t {
+ NEW_GAME_MAP = 0,
+ START_LEVEL_1,
+ START_LEVEL_2,
+ KNOW_ALL_MOVES,
+ MUMBO_COST_TERMITE,
+ MUMBO_COST_CROC,
+ MUMBO_COST_WALRUS,
+ MUMBO_COST_PUMPKIN,
+ MUMBO_COST_BEE,
+ EGGS_NORMAL_MAX,
+ RED_FEATHERS_NORMAL_MAX,
+ GOLD_FEATHERS_NORMAL_MAX,
+ EGGS_CHEATO_MAX,
+ RED_FEATHERS_CHEATO_MAX,
+ GOLD_FEATHERS_CHEATO_MAX,
+ NOTES_MAX,
+ JIGGIES_PER_WORLD,
+ HONEYCOMBS_PER_WORLD,
+ EXTRA_HC_START,
+ WARP_EXIT_BANJOS_HOUSE,
+ WARP_ENTER_LAIR,
+ SPECIAL_LEVEL,
+ HIDE_JIGGIES_LEVEL,
+ HIDE_COLLECTIBLES_LEVEL,
+ COUNT
+};
+
+// Build the binary aGameConfig blob from a BB romhack ROM.
+std::vector<char> BuildGameConfigBlob(const std::vector<uint8_t>& rom, const std::string& romName);
+
+} // namespace BK64
diff --git a/src/factories/bk64/DialogFactory.cpp b/src/factories/bk64/DialogFactory.cpp
index a128504..4a1ef30 100644
--- a/src/factories/bk64/DialogFactory.cpp
+++ b/src/factories/bk64/DialogFactory.cpp
@@ -179,6 +179,22 @@ static DialogLang ParseLangBlock(LUS::BinaryReader& reader) {
lang.bottom.push_back(dialogString);
}
+ // Banjo's Backpack writes a 04 01 00 separator between bottom and top
+ // sections unconditionally, even when bottomSize is 0. When present,
+ // skip it so topSize reads correctly. Only check after an empty bottom
+ // to avoid false matches in vanilla dialogs.
+ if (bottomSize == 0) {
+ uint32_t savedPos = reader.GetBaseAddress();
+ if (savedPos + 3 <= reader.GetLength()) {
+ uint8_t b0 = reader.ReadUByte();
+ uint8_t b1 = reader.ReadUByte();
+ uint8_t b2 = reader.ReadUByte();
+ if (b0 != 0x04 || b1 != 0x01 || b2 != 0x00) {
+ reader.Seek(savedPos, LUS::SeekOffsetType::Start);
+ }
+ }
+ }
+
auto topSize = reader.ReadUByte();
for (uint8_t i = 0; i < topSize; i++) {
DialogString dialogString;
diff --git a/src/main.cpp b/src/main.cpp
index 030eb42..0beffa5 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,6 +1,9 @@
#include <iostream>
#include "CLI11.hpp"
#include "Companion.h"
+#ifdef BK64_SUPPORT
+#include "factories/bk64/ConfigFactory.h"
+#endif
Companion* Companion::Instance;
@@ -140,6 +143,21 @@ int main(int argc, char* argv[]) {
}
});
+#ifdef BK64_SUPPORT
+ /* Emit per-slot SHA-1s of the BK64 asset table */
+ std::string hashesOut;
+ const auto hashes = app.add_subcommand(
+ "hashes", "Hashes - Emit per-slot SHA-1 of BK64 asset table for v1.0 baseline\n");
+
+ hashes->add_option("<baserom.z64>", filename, "")->required()->check(CLI::ExistingFile);
+ hashes->add_option("-o,--output", hashesOut, "Output yaml path (default: hashes.yaml in cwd)");
+
+ hashes->parse_complete_callback([&] {
+ const std::filesystem::path outPath = hashesOut.empty() ? "hashes.yaml" : hashesOut;
+ BK64::EmitAssetHashes(filename, outPath);
+ });
+#endif
+
/* Generate modding files */
const auto modding_root = app.add_subcommand("modding", "Modding - Generates modding files like png\n");
const auto modding_import =