ComboShip runs Ocarina of Time (Ship of Harkinian) and Majora's Mask (2 Ship 2 Harkinian) as a single program that can hand off back and forth between the two games at runtime, while keeping each game's source close enough to upstream that updates can still be merged in.
Status note: This document describes what is actually built and stays high-level; the per-feature deviation records live in
deviations/. The headline cross-game features — a shared cross-world randomizer, immediate cross-game item delivery, Anchor online co-op, and cross-game hints — are implemented (see Cross-Game Features). A single unified/merged save format is not built: the two saves are linked by slot. Earlier revisions described an adapter/observer class architecture that was never built; it has been removed.
- One program, both games — boot into OOT, transition into MM and back, on a shared window and engine.
- Independent upstream tracking — keep
soh/andmm/mergeable with upstream SoH / 2S2H. - Minimal game-code modification (HM64 principle) — touch the decomp source as little as possible.
Combo behavior lives in port code, hooks, and enhancements; the few unavoidable edits to game
source are tiny and fenced with
#ifdef COMBO_BUILD.
The build splits the engine into a shared DLL and produces two game DLLs plus a thin launcher exe. The launcher loads both game DLLs, resolves a small set of exported C entry points, and drives a loop that boots one game and resumes the other across transitions.
┌──────────────────────────────────────────────────────────────┐
│ ComboShip.exe (combo/ComboShip.cpp) │
│ • LoadLibrary soh.dll + 2ship.dll, resolve exports │
│ • Ensure O2R archives exist (extract if missing) │
│ • Bidirectional game-switch loop (boot once, resume after) │
└───────────────┬───────────────────────────┬──────────────────┘
│ C exports + callbacks │ C exports + callbacks
┌───────▼─────────┐ ┌────────▼────────┐
│ soh.dll (OOT) │ │ 2ship.dll (MM) │
│ decomp + hooks │ │ decomp + hooks │
│ + SoH enhance. │ │ + 2S2H enhance.│
└───────┬─────────┘ └────────┬────────┘
│ │
└──────────────┬───────────────┘
┌───────▼─────────────────┐
│ libultraship.dll │
│ (shared engine: GFX, │
│ audio, input, window, │
│ resource management) │
└──────────────────────────┘
COMBO_BUILD (defined globally in the top-level CMakeLists.txt) is what switches the engine from
a statically-linked single binary into the shared libultraship.dll + soh.dll + 2ship.dll
layout and enables the transition hooks in both games.
The launcher talks to each game purely through exported C functions resolved with GetProcAddress
(see combo/ComboShip.cpp). Missing optional exports are tolerated (null-checked); only the core
ones are required. The tables below are the core transition surface — a representative subset.
The full surface (Anchor transport, randomizer oracle exports, cross-game item delivery, save
callbacks) is larger and lives in combo/ComboShip.cpp; see deviations/ per feature.
OOT export (soh.dll) |
Purpose |
|---|---|
SOH_Init |
Build OOT context + resource manager + window (required) |
SOH_RunMain |
Run the OOT game loop; returns on exit/transition (required) |
SOH_Extract |
Launch the OOT asset extractor if archives are missing |
SOH_SetOnNewSaveCallback |
Notify the launcher when a new OOT save is created |
SOH_SetOnSceneSwitchCallback |
Notify the launcher when the Mask Shop transition fires |
SOH_PrepareForTransition |
Quiesce OOT before handing off to MM |
SOH_ResumeGame |
Resume OOT after returning from MM |
SOH_NotifyComboReturn |
Tell OOT a return-from-MM is in progress |
SOH_Deinit |
Fully tear down the OOT context at program exit |
MM export (2ship.dll) |
Purpose |
|---|---|
MM_InitArchives |
(required) MM archive bring-up |
MM_Extract |
Launch the MM ROM extractor if mm.o2r is missing |
MM_InitSaveFile |
Create the MM save matching an OOT slot |
MM_RunGame |
Boot MM into the given file number |
MM_ResumeGame |
Resume MM after returning from OOT |
MM_NotifyComboTransition |
Tell MM an OOT→MM handoff is in progress |
MM_SetOnComboReturnCallback |
Notify the launcher when the Clock Tower return fires |
MM_PrepareForTransition |
Quiesce MM before handing back to OOT |
main() in combo/ComboShip.cpp:
- Load
soh.dlland2ship.dll; resolve exports. - Ensure OOT archives exist (
soh.o2r/oot*.o2r), runningSOH_Extractif not. - Ensure the MM ROM archive exists (
mm.o2r/mm.zip/mm.otr), runningMM_Extractif not. SOH_Init()— bring up OOT.- Register the OOT new-save and scene-switch callbacks.
- Run the bidirectional switch loop:
- OOT side: first entry calls
SOH_RunMain; later entries callSOH_ResumeGame. Entering the Mask Shop sets a pending MM file number, so the loop callsSOH_PrepareForTransition,MM_NotifyComboTransition, registers the MM→OOT return callback, and switches to MM. - MM side: first entry calls
MM_RunGame; later entries callMM_ResumeGame. Entering the Clock Tower sets a pending-return flag, so the loop callsMM_PrepareForTransition,SOH_NotifyComboReturn, and switches back to OOT. - The one-time per-process init (heaps/threads) runs only on the first entry into each game; subsequent entries resume on the shared context/window.
- Ctrl+R reset:
ResetHandlercallsCombo_HandleReset. OOT foreground → normal reset (lands on the boot sequence). MM foreground →MM_RequestComboReturnreuses the return path (MM saves only if autosave is on) and flagsSOH_ResumeGameto leavegComboReturnFileNum = -1, sotitle_setup.cboots toTitle_Init(first-boot) instead of resuming the save.
- OOT side: first entry calls
- On loop exit,
SOH_Deinit()tears down the OOT context that was kept alive across transitions.
- The OOT window and
Ship::Contextstay alive across an OOT↔MM transition rather than being destroyed and recreated (which is what would otherwise open a second window). - Each game has its own
ResourceManager; the active one is swapped on transition. - MM audio name maps are kept resident across transitions so MM audio still resolves after the first handoff.
When a new OOT save is created, the launcher's Combo_OnOOTSaveInit calls MM_InitSaveFile to
create the matching MM save (OOT slot N → MM file N+1). On the OOT→MM handoff, MM's
title_setup loads that file and spawns the player in South Clock Town. This is save linkage by
slot, not a unified/merged save format. Cross-game item grants are written directly into the linked
save of whichever game is dormant (see Cross-Game Features).
These are what ComboShip adds on top of the two ports. Each is summarized here; the load-bearing
details and merge-survival notes live in deviations/.
- Cross-world randomizer — one seed shuffles items across both games; an OOT check can hold an
MM item and vice-versa. The launcher runs the combined fill (
RunComboFill), driving each game's oracle exports (*_Rando_Reset/SetOwnedItems/GetReachableChecks/PlaceItem) so the fill reasons over both games' reachability at once, honoring the OOT→MM portal gate. Seedeviations/rando.md. - Cross-game item delivery — collecting a foreign check grants the item immediately into the
other game's resident save via a save-only export (the target is usually the dormant game, so its
save isn't ticking underneath us), and shares it over Anchor. Replaced the old JSON mailbox
(issue #3). See
deviations/rando.md. - Anchor online co-op — the persistent TCP socket + receive thread live in the launcher so the
connection survives OOT↔MM transitions. Each game redirects its Anchor transport through
launcher-registered callbacks. Supports presence, remote-player puppets, shared-progression item
sync, and late-join resync, cross-game. See
deviations/anchor.md. - Cross-game hints & shared trackers — hints can point across games; check/item trackers show
both games. See
deviations/rando.mdanddeviations/tracker.md.
Edits to decomp source (soh/src/, mm/src/) are kept minimal and fenced:
#ifdef COMBO_BUILD
if (gComboStartFileNum >= 0) {
// combo-only setup
}
#endifThe combo footprint in game source is only a handful of lines per file (e.g. title_setup.c,
graph.c, main.c, z_sram.c). Anything larger lives in the port/enhancement layers
(soh/soh/, mm/2s2h/) or in the launcher.
When pulling from upstream Ship of Harkinian or 2 Ship 2 Harkinian:
- Game code (
soh/src/,mm/src/) — most changes merge cleanly; conflicts only occur at the small#ifdef COMBO_BUILDhook sites. - Enhancements (
soh/soh/,mm/2s2h/) — generally merge cleanly. - Build system — may need adjustments in the top-level
CMakeLists.txt; keep the per-gameCMakeLists.txtfiles as close to upstream as possible.
Resolving a hook conflict — keep the upstream code, re-add the fenced hook:
// Resolution: take upstream body, re-insert the COMBO_BUILD hook
void SomeUpstreamFunction(void) {
DoUpstreamThing();
#ifdef COMBO_BUILD
Combo_OnSomething();
#endif
}- The top-level
CMakeLists.txtdefinesCOMBO_BUILD, adds the sharedlibultraship,ZAPD, andOTRExporter, andadd_subdirectory(combo)for the launcher. - A meta target builds everything:
add_custom_target(combo ALL DEPENDS soh 2ship ComboShip). combo/CMakeLists.txtbuildsComboShip.exefromcombo/ComboShip.cppand, as a POST_BUILD step, copies the three DLLs, the port.o2rarchives, and extractor assets next to the exe.- Convenience build scripts live in
scripts/(build-libultraship.ps1,build-soh.ps1,build-2ship.ps1,build-comboship.ps1). Build targets individually rather than rebuilding everything.
Still not implemented, recorded as intent:
- Combined save format — a single save containing both games' data plus shared state (today the two saves are linked by slot number, with cross-game items written into each game's own save).
- Seamless transition polish — masking load time, transition effects.
(Shared inventory and progression sync, once listed here as future work, now exist for randomizer items via cross-game delivery and for co-op via Anchor.)
If/when the remaining items are built, they should follow the same principles: logic in the launcher
and port layers, with only minimal #ifdef COMBO_BUILD hooks in game source.