Files
Shipwright/torch-migration/PLAN.md
T
briaguya e6495195b9 Add Torch migration plan
Plan for replacing ZAPDTR + OTRExporter with Torch as SoH's only asset
pipeline, following HarbourMasters/Torch#219 ("oot support").

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-24 21:18:00 -04:00

27 KiB
Raw Blame History

Migrate Shipwright from ZAPDTR/OTRExporter to Torch

Context

SoH extracts OoT assets from the user's ROM with ZAPDTR (a ZAPD fork) driven by OTRExporter, described by 7,680 ZAPD XMLs under soh/assets/xml/. Both are git submodules, and ZAPD is additionally linked into the game binary so users can drag a ROM onto SoH and get an oot.o2r with a progress bar.

HarbourMasters/Torch#219 ("oot support", merged 2026-07-24) landed the OoT factories in Torch. briaguya0/zapd-to-torch-test-harness proves the swap is safe: 14 of 14 ROM targets extract byte-for-byte identical to an OTRExporter reference O2R from Shipwright 95d8f7e (35,38639,066 assets per ROM, 0 failed / 0 missing / 0 extra).

Goal: delete ZAPDTR and OTRExporter entirely; Torch becomes the only asset pipeline. Shape: one big PR — develop is never half-migrated.

Verified compatibility that makes this viable: Torch's 0x40-byte resource header (src/factories/BaseFactory.cpp:3) already matches libultraship/src/ship/resource/ResourceLoader.cpp:246-259, and Torch's OoT FourCC ResourceTypes are byte-identical to soh/soh/resource/type/SohResourceType.h.


Decisions

Question Decision
ZAPDTR / OTRExporter Full deletion
Torch consumption FetchContent, pinned to HarbourMasters/Torch 4cae44160693e1beb562e39dc301bd42278be1f9
Asset YAML home Shipwright, but a separate repo consumed as a submodule for PR reviewability; flatten into the tree post-merge
In-game extraction Keep it — link Torch as a static lib (USE_STANDALONE=OFF) and drive Companion directly
soh.o2r Small in-tree C++ packer (no Python — Windows devs). Long term: Torch PR adding PNG support to Companion::Pack
ExtractAssetHeaders Dropped. Freeze the 1,085 checked-in .h files; follow-up Torch PR (see Blocker below)
Release payload Ship the raw 111 MB YAML tree; zip it (~15 MB) in a follow-up
Dep collisions Try FetchContent as-is on Linux + Windows first, fix only what actually breaks

Pin note: git diff --stat 9422bf4 upstream/main = 0 lines; the harness-pinned fork commit and HarbourMasters/Torch main have identical trees (5d247f78…). Nothing to upstream for parity. Re-check git diff 4cae4416 <pin> right before opening the PR in case main moves.


Blocker resolved up front: headers

torch header cannot replace ExtractAssetHeaders, for two independent reasons:

  1. No OoT factory registers a Header exporter. Every REGISTER( in src/factories/oot/*.h is REGISTER(Binary, ...). Companion::ParseNode skips assets whose factory lacks an exporter for the active ExportType (Companion.cpp:882-884), so ~10k assets — skeletons, limbs, animations, collision, arrays, paths, cutscenes, text, scenes, rooms — would be silently omitted. gLinkAdultSkel simply would not appear.
  2. No #define d<sym> line. Torch emits only static const ALIGN_ASSET(2) char sym[] = "__OTR__path";. SoH's headers emit a pair, and soh source references the d-prefixed macros 24,698 times. Also #pragma once vs #ifndef OBJECTS_..._H, no #include "align_asset_macro.h", and a flat headers/objects/foo.h layout vs SoH's objects/foo/foo.h.

The load-bearing part — the __OTR__objects/…/gSym path string — already matches byte-for-byte (that's what the harness proves). What's missing is presentation plus ~14 mechanical Header exporters. This PR deletes the target and keeps the .h files as ordinary checked-in source (they already are, and contain no ZAPD-derived includes beyond align_asset_macro.h). soh/assets/soh_assets.h is hand-written and was never generated. Document loudly in the PR that header regeneration is temporarily unavailable; file the Torch PR in parallel.


Phase 0 — Verification gates (no Shipwright changes)

All run in zapd-to-torch-test-harness against the committed manifests/*.json.

Gate A — USE_STANDALONE=OFF is byte-identical. The static-lib build has no CLI, so add a throwaway driver in the harness (torchlib-driver/: add_subdirectory(../torch) with USE_STANDALONE=OFF, ~40-line main.cpp constructing Companion and calling Init(ExportType::Binary)). Build both ways from the same tree with -DPORT_VERSION_ENDIANNESS=ON, run all 14 versions, compare via check.sh. Expected identical: the only reachable #ifdef STANDALONE difference is enums: parsing (Companion.cpp:1524) and the Code exporter — and assets/yml/config.yml has no enums: key. Smoke-test pal_gc first.

Gate B — OoT-only build is byte-identical. -DBUILD_{SM64,MK64,SF64,PM64,FZERO,BK64,MARIO_ARTIST,NAUDIO}=OFF. Run the 14-target matrix (ROM_VERSION=<v> python3 tools/test_assets.py roms/<rom>.z64). The 14/14 parity was measured with defaults ON, so this is a genuine change. Two real effects: BUILD_BK64=OFF drops the BK64::TrySynthesizeRomConfig fallback (irrelevant — all 17 hashes are in config.yml), and BUILD_NAUDIO=OFF removes Torch's only tinyxml2 consumers.

Gate A — the shipping configuration. Re-run Gate A with the Gate B flags. This exact combination is what ships.

Gate E — soh.o2r baseline. From current develop, build GenerateSohOtr and record a sha256 manifest of all 1,041 entries. This is the acceptance test for the new packer. All 31 convertible PNGs are 8-bit / colour-type 6, so decode risk is low; the risk is quantisation (see Phase 4).

Gate D — upstream pin. Already satisfied (trees equal). Just record the SHA.

Nothing in Shipwright changes until A, B, A, E are green.


Phase 1 — The asset YAML repo

New repo (e.g. HarbourMasters/soh-assets), submoduled at assets/ (top level, sibling of soh/) so the eventual flatten is one git rm + git add of a single directory.

config.yml            17 ROM SHA1 → 14 version dirs (gbi: F3DEX2_OoT, sort: OFFSET,
                      primary_virtual_segment: 0x80, strict_declarations: true)
<14 version dirs>/    20,353 .yml, 111 MB
xml/                  SNAPSHOT of soh/assets/xml before deletion  ← most important item here
filelists/ symbols/   moved from soh/assets/extractor (generator inputs)
dma/ supplemental/    17 JSON each (generator inputs)
tools/                zapd_to_torch.py, extract_dma.py, generate_supplemental.py
manifests/            14 sha256 manifests from the OTRExporter reference
README.md             regeneration recipe + which SoH commit the reference came from

Python lives here, not in Shipwright — SoH devs never need it; regeneration is a maintainer task. Regeneration: extract_dma.pygenerate_supplemental.pyzapd_to_torch.pymanifest.sh → re-run the 14-target check.

Snapshotting soh/assets/xml/ into this repo before deleting it is non-negotiable — it is the only input from which the YAML can be regenerated.


Phase 2 — CMake

Root CMakeLists.txt

Add before add_subdirectory(libultraship) (~L196) — ordering matters, Torch declares tinyxml2 and zlib with OVERRIDE_FIND_PACKAGE:

include(FetchContent)
set(USE_STANDALONE          OFF CACHE BOOL "" FORCE)  # static lib, linkable into soh
set(PORT_VERSION_ENDIANNESS ON  CACHE BOOL "" FORCE)  # 7-byte portVersion, OTRExporter parity
set(ROM_CRC_BSWAP           OFF CACHE BOOL "" FORCE)
set(BUILD_UI                OFF CACHE BOOL "" FORCE)  # would FetchContent a 2nd libultraship
set(BUILD_STORMLIB          OFF CACHE BOOL "" FORCE)  # MANDATORY: target-name clash with LUS's `storm`
set(BUILD_OOT               ON  CACHE BOOL "" FORCE)
foreach(g SM64 MK64 SF64 PM64 FZERO BK64 MARIO_ARTIST NAUDIO)
    set(BUILD_${g} OFF CACHE BOOL "" FORCE)
endforeach()
FetchContent_Declare(torch
    GIT_REPOSITORY https://github.com/HarbourMasters/Torch.git
    GIT_TAG        4cae44160693e1beb562e39dc301bd42278be1f9)
FetchContent_MakeAvailable(torch)

Remove: add_subdirectory(ZAPDTR/ZAPD …) (L199), add_subdirectory(OTRExporter) (L200), both install(TARGETS ZAPD …) (L209, L292), the soh/assets/extractor + soh/assets/xml install rules (L210-211, 215-216, 296-297), find_package(Python3 …) (L219), and the three custom targets (L222-261). Fix the stale L178 comment ("to use libgfxd from ZAPDTR" — LUS FetchContents libgfxd itself, so the Gfx debugger is unaffected).

Two new in-tree tools

Torch can't be added twice, and the static-lib build has no main (int main is inside #if defined(STANDALONE), while Companion* Companion::Instance; at src/main.cpp:15 is deliberately outside it — so the lib gets the singleton storage and no entry point).

  • tools/torch-cli/ → target soh-torch. ~40 lines: parse --src/--dest/--version/--rom, construct Companion, SetVersion, Init(ExportType::Binary). Links torch. One Torch build serves both the game link and the build-time targets; CI never needs the upstream CLI.
  • tools/soh-o2r-packer/ → see Phase 4.

Replacement targets

add_custom_target(ExtractAssets
    COMMAND ${CMAKE_COMMAND} -E rm -f oot.o2r oot-mq.o2r
    COMMAND $<TARGET_FILE:soh-torch> --src ${CMAKE_SOURCE_DIR}/assets
            --dest ${CMAKE_BINARY_DIR}/soh --version ${CMAKE_PROJECT_VERSION} --rom ${SOH_ROM_PATH}
    DEPENDS soh-torch)

add_custom_target(GenerateSohOtr
    COMMAND ${CMAKE_COMMAND} -E rm -r -f ${CMAKE_SOURCE_DIR}/soh/assets/custom/shaders/
    COMMAND ${CMAKE_COMMAND} -E copy_directory
            ${CMAKE_SOURCE_DIR}/libultraship/src/fast/shaders/ ${CMAKE_SOURCE_DIR}/soh/assets/custom/shaders/
    COMMAND $<TARGET_FILE:soh-o2r-packer> ${CMAKE_SOURCE_DIR}/soh/assets/custom
            ${CMAKE_BINARY_DIR}/soh/soh.o2r ${CMAKE_PROJECT_VERSION}
    DEPENDS soh-o2r-packer)
# ExtractAssetHeaders: DELETED

The LUS-shader copy must be preserved verbatim — it's why soh/assets/custom/shaders/ exists and it currently lives inside both deleted targets.

soh/CMakeLists.txt

  • L107-109: drop the ZAPDLib add_subdirectory guard (Torch comes from the root).
  • L325: delete the dead ../ZAPDTR/ZAPD/resource/type include — that directory doesn't exist.
  • L599-612: swap the soh/assets/extractor + soh/assets/xml POST_BUILD copies for a copy of ${CMAKE_SOURCE_DIR}/assets; drop the ZAPD-only assets/symbols mkdir.
  • L624/642/662/704: ZAPDLibtorch, keeping the existing NOT CMAKE_SYSTEM_NAME MATCHES "NintendoSwitch|CafeOS" guard — Switch and Wii U don't link the extractor today and must not link Torch either.
  • Add soh/soh/Extractor/TorchExtract.cpp.

Known collision landmines (fix only if they fire)

Dep Risk
zlib Torch FetchContent_Declare(zlib … OVERRIDE_FIND_PACKAGE) unconditionally and links zlibstatic — but grep zlib.h torch/src = 0 hits; it's only reachable via StormLib, which we disable. Redirects every later find_package(ZLIB) (libpng, libzip, SDL) and defines zlibstatic but not ZLIB::ZLIB. Highest risk. Best fix is a one-line Torch PR gating the fetch on BUILD_STORMLIB OR BUILD_UI.
stb lib/n64graphics/CMakeLists.txt:13-31 renames stbi_write_*n64g_stbi_write_* only if(BUILD_UI). stbi_load* is never renamed. LUS links its own stb.
StringHelper / StrHash64 Torch excludes its copies only if(BUILD_UI) (CMakeLists.txt:146-152), citing exactly this clash. SoH has the same files in libultraship/include/ship/utils/.
tinyxml2 OVERRIDE_FIND_PACKAGE hijacks LUS's find_package(tinyxml2 REQUIRED). Torch fetches 10.0.0 — the same version .github/actions/install-tinyxml2 installs, so declaring Torch first is likely beneficial (one copy instead of two). All Torch tinyxml2 consumers are naudio, which we disable.
spdlog find_package(spdlog QUIET) runs first and SoH always has vcpkg spdlog, so no ABI clash. But Torch calls spdlog::set_level/set_pattern on the global default logger from the worker thread (Companion.cpp:193-194, 1810-1814), clobbering SoH's config at OTRGlobals.cpp:808-815. Save/restore around the call.
storm Hard CMake error if BUILD_STORMLIB=ON (LUS already creates a storm target, and INCLUDE_MPQ_SUPPORT is ON at root L186). Avoided by keeping it OFF; .o2r uses vendored miniz anyway.

Phase 3 — In-game extractor rewrite

Extractor::CallZapd (soh/soh/Extractor/Extract.cpp:641) currently chdirs into a temp dir with assets/ symlinked and hand-builds 22 argv entries for extern "C" zapd_report(argc, argv, extractCount, totalExtract). Everything that needed the chdir becomes constructor arguments — grep current_path torch/src hits only main.cpp (STANDALONE) and LusBackend.cpp (BUILD_UI), so Torch has no CWD dependence when both srcdir and destdir are passed.

Isolate Torch in its own TU. Companion.h pulls in yaml-cpp, n64/Cartridge.h, a bare CONTAINS macro, and the torch target exports Torch's src/ and lib/ (CLI11, TinySHA1, hj/, nposix/) as PUBLIC includes. Don't mix that into Extract.cpp alongside game headers.

  • soh/soh/Extractor/TorchExtract.h — declarations only, no Torch types: SohTorch::CountAssetFiles(ymlDir) and SohTorch::Extract(rom, srcDir, destDir, portVersion, std::atomic<size_t>* progress).
  • soh/soh/Extractor/TorchExtract.cpp — the only file that includes Companion.h.

Flow: srcDir = <installPath>/assets; destDir = Mkdtemp() (reuse Extract.cpp:619); totalExtract = recursive count of *.yml under srcDir/<versionDir> minus config.yml; run; then rename(destDir/oot.o2r → exportdir/(IsMasterQuest() ? "oot-mq.o2r" : "oot.o2r")); remove_all(destDir).

Driving Companion:

auto* c = new Companion(fs::path(rom), ArchiveType::O2R, /*debug*/false, srcDir, destDir);
Companion::Instance = c;          // bare singleton, no getter — factories deref it unconditionally
c->SetVersion(portVersion);
c->SetPhaseCallback([progress](int){ if (progress) ++*progress; });
c->Init(ExportType::Binary);      // Init IS the whole run; it calls Process() internally
  • Must try/catch. 127 throw std::runtime_error across torch/src; nothing catches at the top level, so an escape means std::terminate. The existing extractionTask->get() try/catch at OTRGlobals.cpp:730-734 turns a rethrow into the "Extraction Crashed" popup.
  • Must also stat the output. Process() returns void and several fatal conditions just log and return — no config.yml (Companion.cpp:1304), no config entry for the ROM hash (1334), no config: node (1390), bad GBI (1484). A ROM dump outside the 17-hash table produces no archive and no exception.
  • Fresh Companion per call. gProcessedFiles (Companion.h:300) is never cleared — a second Process() on the same instance skips every file. gAddrMap/gParseResults/gWriteMap also accumulate. RunExtract can extract more than once per process (PS_FIRSTPS_SECOND, ES_EXTRACT_ARGS loops over CLI args), so allocate anew and reassign Companion::Instance. Watch: AliasManager::Instance is not cleared at end of Process (inert for OoT — no OoT factory calls Register — but unproven), and AudioManager::Instance is leaked per run (Companion.cpp:1584).

Progress: use SetPhaseCallback, not Init's atomic overload. ProcessFile (Companion.cpp:1282-1293) resets *gAssetCounter = 0 and recomputes the total once per YAML file, so with ~1,450 YAMLs a naive bar restarts 1,450 times. The phase callback has exactly one call site (gPhaseCallback(2) at Companion.cpp:1282-1283), fired once per YAML at the parse→export transition, after NodeHasChanges has already let the file through — a 1:1 match for a denominator computed by counting files on disk. Sub-files from AddSubFileAsset are virtual names never present on disk, so the mapping is exact. NodeHasChanges early-returns are impossible because a fresh temp destdir has no torch.hash.yml. Nothing in the ImGui progress bar (OTRGlobals.cpp:750-755) needs to change. GetCurrentAssetName() is available for a label.

The temp destdir is mandatory, not cosmetic. Process unconditionally writes destdir/torch.hash.yml (Companion.cpp:1804) and reads it back next run (:708). Extracting straight into the app dir would litter it and make a second in-session extraction skip files it thinks are unchanged.

MQ naming: leave config.yml at binary: oot.o2r for all 17 hashes and rename on the way out of the temp dir based on IsMasterQuest(). Torch offers no output-filename override (-d sets only the directory; you'd get a directory named oot-mq.o2r), and this keeps SoH the single source of truth for MQ-ness and preserves the harness's <out>/oot.o2r expectation.

Delete: CallZapd (Extract.cpp:640-696) and its decl in Extract.h:66-67, the zapd_report extern, and GetZapdVerStr() (:583) — replaced by a GetTorchVersionDir() returning pal_gc/ntsc_u_gc/… over the same CRC mapping. Mkdtemp() stays.


Phase 4 — The soh.o2r packer

tools/soh-o2r-packer/, target_link_libraries(soh-o2r-packer PRIVATE torch). Reusing torch gets ZWrapper (miniz — the same zip writer that produces oot.o2r, so archives stay structurally consistent), Companion::ParseVersionString for the 7-byte portVersion, and stb_image via the vendored N64Graphics. No new dependency, no Python, works on Windows out of the box.

Usage: soh-o2r-packer <custom-assets-dir> <out.o2r> <M.m.p>. Behaviour ported from OTRExporter/OTRExporter/Main.cpp:206-260:

  1. <name>.<fmt>.png (rgba32|rgb5a1|i4|i8|ia4|ia8|ia16|ci4|ci8) → convert; archive path is the relative path with .<fmt>.png stripped. Payload = the 0x40 resource header (endianness 0x00, resType Texture, version 0, id 0xDEADBEEF…, zero-padded) then u32 textureType, width, height, rawDataSize, then the raw N64 bytes. Reproduce ZAPD's asymmetry: the size field is the unpadded GetRawDataSize() while the buffer written is ALIGN8-padded.
  2. Path contains accessibility.json added with full relative path; non-.json skipped. All 12 files are .json today, so this is a no-op — replicate it anyway to avoid silent drift.
  3. Everything else → raw byte copy, full relative path.
  4. portVersion only. No version file — OTRExporter doesn't write one for soh.o2r, and sohArchiveVersionMatch (OTRGlobals.cpp:282-283) only reads portVersion.

Critical: use ZAPD's quantisation, not n64graphics'. n64graphics' ia2raw uses SCALE_8_4(x) (x*15/255) where ZAPD uses (x >> 4) & 0xF — for r = 0xF0 those give 0xE vs 0xF, different bytes. Same for IA4. Decode with stbi_load(path, &w, &h, &n, 4), then hand-port ~80 lines from ZAPDTR/ZAPD/ZTexture.cpp: rgba32 (:507), ia16 (:595, [r, a]), ia8 (:578, ((r>>4)&0xF)<<4 | ((a>>4)&0xF)), ia4 (:552, ((r>>5)<<1) | (a != 0)), plus i4/i8/rgba16/ci4/ci8 for completeness so adding a texture later doesn't silently produce garbage.

Only 31 of 1,041 files hit the conversion path (15 rgba32, 8 ia8, 5 ia16, 3 ia4); the rest — 923 objects, 23 .rgba16, 22 json, 4 ttf, 2 xml, ~40 plain .png — are raw copies.

Acceptance: sha256 of every one of the 1,041 entries against the Gate E manifest.

Long term: the Torch PR is Companion::Pack gaining PNG conversion + a version file, after which this collapses to torch pack soh/assets/custom soh.o2r o2r -u 9.2.3. Structure the format table so it lifts into Torch wholesale.


Phase 5 — Deletions

  • Submodules: ZAPDTR/ (1.9 MB), OTRExporter/ (71 MB) — .gitmodules + git rm
  • soh/assets/xml/ — 7,680 files, 54 MB (snapshot into the assets repo first)
  • soh/assets/extractor/Config_*.xml (14) and TexturePool.xml — ZAPD -rconf inputs, no runtime use
  • soh/assets/extractor/filelists/, soh/assets/extractor/symbols/move to the assets repo (extract_dma.py needs filelists; symbols are referenced only from the deleted Config_*.xml, with no runtime reader in soh/soh or soh/src)
  • copy-existing-otrs.cmake
  • CMake and source hunks listed in Phases 23

Keep: all 1,085 soh/assets/**/*.h, all 1,041 files under soh/assets/custom/.

Net: 127 MB submodules, 54 MB XML, +111 MB YAML (submodule initially; ~7-15 MB packed).


Phase 6 — CI

.github/workflows/generate-builds.yml:

  • generate-soh-otr (L10-68) does a full SoH configure — SDL2 from source, SDL2_net, tinyxml2 from source, ccache, apt deps — purely to build GenerateSohOtr. The new packer has no SDL/LUS dependency, so add a SOH_TOOLS_ONLY root guard that skips add_subdirectory(libultraship)/add_subdirectory(soh) and configures only Torch + soh-o2r-packer. Saves ~5-8 min per run and drops the install-tinyxml2 dependency. Add a step asserting the soh.o2r content manifest.
  • Platform build jobs (build-macos L71, build-linux, build-windows, Switch, Wii U): submodules: true already covers the new assets/ submodule (it has no nested submodules). Switch/Wii U must not build Torch — same NintendoSwitch|CafeOS guard as ZAPDLib today. Package payload changes from assets/xml/ to assets/.
  • test-builds-on-distros.yml L137-150 removes distro tinyxml2 and builds 10.0.0 from source. Leave it in this PR — one variable at a time; delete in a follow-up once Torch's fetched tinyxml2 is confirmed to satisfy LUS.
  • pr-artifacts.yml:51 already filters soh.o2r; no change.

Optional follow-up: a CI job running Gate A/B against a checked-in manifest so Torch pin bumps are validated automatically. Needs a ROM, so self-hosted or encrypted secret.


Risks, ranked

  1. zlib double-link — Torch links an unused zlib and OVERRIDE_FIND_PACKAGE hijacks the whole build's find_package(ZLIB). Most likely thing to break the link, especially on Windows/vcpkg-static.
  2. stb / StringHelper / StrHash64 duplicate symbols — Torch's own guards for these are if(BUILD_UI), which we don't set.
  3. soh.o2r texture byte-identity — libpng→stb_image plus the SCALE_8_4 vs >>4 trap. Fully testable offline (Gate E); de-risk first, costs an afternoon.
  4. Windows build — Torch forces /MT, adds -DSTORMLIB_NO_AUTO_LINK, /bigobj, and carries a cmake_minimum_required(3.12) + policy shim under a CMake 4 host. The one platform not predictable from reading. Build it early.
  5. Extraction wall-clock — Torch parses ~1,450 YAMLs per run and the parse phase is serial (export is explicitly serial for Binary too: "Binary/code/header all share wrapper state", Companion.cpp:1748). Measure vs ZAPD; if it regresses, the UI needs a "this takes N minutes" note.
  6. Re-entrancy across two in-session extractionsAliasManager uncleaned plus file-scope statics in TextureFactory.cpp:14-15, CompressedTextureFactory.cpp:17-18, DisplayListFactory.cpp:82. Believed inert for OoT/Binary; only a real vanilla-then-MQ run proves it.
  7. spdlog global-state stomping from Torch's worker thread.
  8. Header regeneration gap until the follow-up Torch PR lands.
  9. 111 MB submodule ergonomics — clone time, and a flatten commit GitHub will refuse to render.
  10. Companion::Instance is a raw globalRunExtract uses a 1-thread pool so it's serialised today; add a comment/assert.

Verification

Archive level

  1. Gates A, B, A, E (Phase 0) — 14/14, 0 failed / 0 not generated / 0 not in reference.
  2. Post-integration: run Shipwright's soh-torch over all 14 ROMs and feed the outputs to the harness's check.sh/test_assets.py. This proves the Shipwright-configured Torch (FetchContent, OoT-only, static lib, SoH's flags) matches — not just the harness build.
  3. soh.o2r: full 1,041-file sha256 diff vs an OTRExporter archive from develop HEAD.
  4. Diff the in-game extractor's oot.o2r against soh-torch's for the same ROM — different code paths (temp destdir, phase callback), must agree byte-for-byte.

Build level 5. Clean configure + build on Linux (gcc, clang), macOS (universal), Windows (MSVC x64), Switch, Wii U. Switch/Wii U build with Torch excluded. 6. Confirm exactly one tinyxml2 and one zlib in the final link (nm/dumpbin for duplicate inflate; cmake --graphviz for target duplication). 7. Confirm soh links with the untouched checked-in .h files after ZAPDTR is gone.

Run the game 8. Cold start with no archives: ROM picker → progress bar (must be monotonic 0→100, not sawtooth) → "Extraction Complete" → boots. 9. Two-ROM session — extract vanilla, answer "Yes" to extract another, extract MQ in the same process. Highest-value manual test: covers re-entrancy and both output names. 10. CLI path: ./soh <rom.z64>ES_EXTRACT_ARGS. 11. Failure paths: non-OoT ROM and a truncated ROM → clean popup, not a hang or half-written .o2r. 12. No litter: no torch.hash.yml, no temp dir surviving in the app directory. 13. Gameplay: boot vanilla and MQ saves, load a dungeon (scene/room/collision), open the pause menu, check PNG-path textures (gSun, gTriforcePiece, gDPad), play a cutscene and a sequence, and confirm the ValidHashes check at OTRGlobals.cpp:941 accepts the new archives. 14. Build a CPack artifact per platform, unzip fresh, run from that directory, re-extract — catches broken install rules for the assets payload.


Ordering

0.  Torch PR: gate the zlib fetch, make stb/StringHelper guards unconditional  [only if they fire]
1.  Harness: Gates A, B, A
2.  New assets repo: snapshot soh/assets/xml, publish YAML + generators
3.  Gate E: soh.o2r manifest from current develop
--- single Shipwright PR from here ---
4.  Add assets submodule; FetchContent Torch before libultraship
5.  tools/torch-cli + tools/soh-o2r-packer
6.  Replace ExtractAssets / GenerateSohOtr; delete ExtractAssetHeaders
7.  TorchExtract.{h,cpp} + Extract.{h,cpp} rewrite
8.  Delete ZAPDTR, OTRExporter, soh/assets/xml, extractor inputs
9.  CI: generate-soh-otr slim-down, packaging payload
10. Full verification matrix
--- follow-ups ---
11. Torch PR: OoT Header exporters + `#define d<sym>` → restore ExtractAssetHeaders
12. Torch PR: PNG support in Companion::Pack → retire soh-o2r-packer
13. Zip the shipped YAML (111 MB → ~15 MB)
14. Flatten the assets submodule into the tree

Critical files

  • CMakeLists.txt (178-179, 196-201, 208-216, 219-261, 292-297)
  • soh/CMakeLists.txt (107-109, 325, 599-612, 624/642/662/704)
  • soh/soh/Extractor/Extract.cpp (CallZapd 640-696, GetZapdVerStr 583, Mkdtemp 619), Extract.h:66-67
  • soh/soh/OTRGlobals.cpp (RunExtract 398, extraction task 596-680, progress bar 750-755, ValidHashes 941)
  • OTRExporter/OTRExporter/Main.cpp:183-268 and ZAPDTR/ZAPD/ZTexture.cpp:507-620 — the spec for Phase 4
  • Torch src/Companion.{h,cpp} (Init 185-395, Process 1299-1819, Pack 1821, torch.hash.yml 708/1804, phase callback 1282-1293), src/main.cpp:15, CMakeLists.txt (21-37, 146-152, 228-235, 402-428), lib/n64graphics/CMakeLists.txt:13-31