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>
27 KiB
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,386–39,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:
- No OoT factory registers a Header exporter. Every
REGISTER(insrc/factories/oot/*.hisREGISTER(Binary, ...).Companion::ParseNodeskips assets whose factory lacks an exporter for the activeExportType(Companion.cpp:882-884), so ~10k assets — skeletons, limbs, animations, collision, arrays, paths, cutscenes, text, scenes, rooms — would be silently omitted.gLinkAdultSkelsimply would not appear. - No
#define d<sym>line. Torch emits onlystatic const ALIGN_ASSET(2) char sym[] = "__OTR__path";. SoH's headers emit a pair, and soh source references thed-prefixed macros 24,698 times. Also#pragma oncevs#ifndef OBJECTS_..._H, no#include "align_asset_macro.h", and a flatheaders/objects/foo.hlayout vs SoH'sobjects/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.py → generate_supplemental.py → zapd_to_torch.py →
manifest.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/→ targetsoh-torch. ~40 lines: parse--src/--dest/--version/--rom, constructCompanion,SetVersion,Init(ExportType::Binary). Linkstorch. 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
ZAPDLibadd_subdirectoryguard (Torch comes from the root). - L325: delete the dead
../ZAPDTR/ZAPD/resource/typeinclude — that directory doesn't exist. - L599-612: swap the
soh/assets/extractor+soh/assets/xmlPOST_BUILD copies for a copy of${CMAKE_SOURCE_DIR}/assets; drop the ZAPD-onlyassets/symbolsmkdir. - L624/642/662/704:
ZAPDLib→torch, keeping the existingNOT 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)andSohTorch::Extract(rom, srcDir, destDir, portVersion, std::atomic<size_t>* progress).soh/soh/Extractor/TorchExtract.cpp— the only file that includesCompanion.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. 127throw std::runtime_erroracrosstorch/src; nothing catches at the top level, so an escape meansstd::terminate. The existingextractionTask->get()try/catch atOTRGlobals.cpp:730-734turns a rethrow into the "Extraction Crashed" popup. - Must also stat the output.
Process()returnsvoidand several fatal conditions just log andreturn— noconfig.yml(Companion.cpp:1304), no config entry for the ROM hash (1334), noconfig:node (1390), bad GBI (1484). A ROM dump outside the 17-hash table produces no archive and no exception. - Fresh
Companionper call.gProcessedFiles(Companion.h:300) is never cleared — a secondProcess()on the same instance skips every file.gAddrMap/gParseResults/gWriteMapalso accumulate.RunExtractcan extract more than once per process (PS_FIRST→PS_SECOND,ES_EXTRACT_ARGSloops over CLI args), so allocate anew and reassignCompanion::Instance. Watch:AliasManager::Instanceis not cleared at end ofProcess(inert for OoT — no OoT factory callsRegister— but unproven), andAudioManager::Instanceis 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:
<name>.<fmt>.png(rgba32|rgb5a1|i4|i8|ia4|ia8|ia16|ci4|ci8) → convert; archive path is the relative path with.<fmt>.pngstripped. Payload = the 0x40 resource header (endianness0x00, resTypeTexture, version 0, id0xDEADBEEF…, zero-padded) thenu32 textureType, width, height, rawDataSize, then the raw N64 bytes. Reproduce ZAPD's asymmetry: the size field is the unpaddedGetRawDataSize()while the buffer written isALIGN8-padded.- Path contains
accessibility→.jsonadded with full relative path; non-.jsonskipped. All 12 files are.jsontoday, so this is a no-op — replicate it anyway to avoid silent drift. - Everything else → raw byte copy, full relative path.
portVersiononly. Noversionfile — OTRExporter doesn't write one forsoh.o2r, andsohArchiveVersionMatch(OTRGlobals.cpp:282-283) only readsportVersion.
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) andTexturePool.xml— ZAPD-rconfinputs, no runtime usesoh/assets/extractor/filelists/,soh/assets/extractor/symbols/— move to the assets repo (extract_dma.pyneeds filelists; symbols are referenced only from the deletedConfig_*.xml, with no runtime reader insoh/sohorsoh/src)copy-existing-otrs.cmake- CMake and source hunks listed in Phases 2–3
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 buildGenerateSohOtr. The new packer has no SDL/LUS dependency, so add aSOH_TOOLS_ONLYroot guard that skipsadd_subdirectory(libultraship)/add_subdirectory(soh)and configures only Torch +soh-o2r-packer. Saves ~5-8 min per run and drops theinstall-tinyxml2dependency. Add a step asserting thesoh.o2rcontent manifest.- Platform build jobs (
build-macosL71,build-linux,build-windows, Switch, Wii U):submodules: truealready covers the newassets/submodule (it has no nested submodules). Switch/Wii U must not build Torch — sameNintendoSwitch|CafeOSguard asZAPDLibtoday. Package payload changes fromassets/xml/toassets/. test-builds-on-distros.ymlL137-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:51already filterssoh.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
- zlib double-link — Torch links an unused zlib and
OVERRIDE_FIND_PACKAGEhijacks the whole build'sfind_package(ZLIB). Most likely thing to break the link, especially on Windows/vcpkg-static. - stb / StringHelper / StrHash64 duplicate symbols — Torch's own guards for these are
if(BUILD_UI), which we don't set. soh.o2rtexture byte-identity — libpng→stb_image plus theSCALE_8_4vs>>4trap. Fully testable offline (Gate E); de-risk first, costs an afternoon.- Windows build — Torch forces
/MT, adds-DSTORMLIB_NO_AUTO_LINK,/bigobj, and carries acmake_minimum_required(3.12)+ policy shim under a CMake 4 host. The one platform not predictable from reading. Build it early. - 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. - Re-entrancy across two in-session extractions —
AliasManageruncleaned plus file-scope statics inTextureFactory.cpp:14-15,CompressedTextureFactory.cpp:17-18,DisplayListFactory.cpp:82. Believed inert for OoT/Binary; only a real vanilla-then-MQ run proves it. - spdlog global-state stomping from Torch's worker thread.
- Header regeneration gap until the follow-up Torch PR lands.
- 111 MB submodule ergonomics — clone time, and a flatten commit GitHub will refuse to render.
Companion::Instanceis a raw global —RunExtractuses a 1-thread pool so it's serialised today; add a comment/assert.
Verification
Archive level
- Gates A, B, A′, E (Phase 0) — 14/14,
0 failed / 0 not generated / 0 not in reference. - Post-integration: run Shipwright's
soh-torchover all 14 ROMs and feed the outputs to the harness'scheck.sh/test_assets.py. This proves the Shipwright-configured Torch (FetchContent, OoT-only, static lib, SoH's flags) matches — not just the harness build. soh.o2r: full 1,041-file sha256 diff vs an OTRExporter archive fromdevelopHEAD.- Diff the in-game extractor's
oot.o2ragainstsoh-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(CallZapd640-696,GetZapdVerStr583,Mkdtemp619),Extract.h:66-67soh/soh/OTRGlobals.cpp(RunExtract398, extraction task 596-680, progress bar 750-755, ValidHashes 941)OTRExporter/OTRExporter/Main.cpp:183-268andZAPDTR/ZAPD/ZTexture.cpp:507-620— the spec for Phase 4- Torch
src/Companion.{h,cpp}(Init185-395,Process1299-1819,Pack1821,torch.hash.yml708/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