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

459 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](https://github.com/HarbourMasters/Torch/pull/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 `ResourceType`s 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.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`:
```cmake
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
```cmake
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: `ZAPDLib``torch`, 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:**
```cpp
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_FIRST``PS_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 extractions**`AliasManager` 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 global** — `RunExtract` 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`