Skip to content

proof-of-concept: mm support - #254

Draft
briaguya0 wants to merge 24 commits into
HarbourMasters:mainfrom
briaguya0:mm-support
Draft

proof-of-concept: mm support#254
briaguya0 wants to merge 24 commits into
HarbourMasters:mainfrom
briaguya0:mm-support

Conversation

@briaguya0

Copy link
Copy Markdown
Contributor

what this does

generates a matching o2r (to https://github.com/briaguya0/2ship2harkinian/tree/deterministic-extraction) when using an NTSC-U 1.0 rom

testing

https://github.com/briaguya0/zapd-to-torch-test-harness/tree/2ship

what's with the 2ship branch?

it's just Battler Alfa with a change to OTRExporter for determinism (ran into the same "we write garbage" thing when porting soh)

what's left to do/why is this a draft?

i still need to actually go through the code in here and make it not garbage. the goal of this pass was "get it to build a matching o2r" not "get it to a state where landing any of this code is a good idea"

sharing as a draft mostly so people can ping me on it and say "hey bria when are you gonna finish this?"

louist103 and others added 24 commits August 12, 2026 22:22
GetFileOffsetFromNodeStr gated on IsValidHex, which requires an 0x
prefix and at least three characters. A bare "0" fails that, falls
through to the filelist lookup, and throws for a name that was never
meant to be one. IsValidOffset exists for exactly this case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven MM files under archives/ are not plain data at their DMA offset.
They are containers: a table of big-endian u32 offsets followed by one
independent Yaz0 stream per asset, loaded in-game by CmpDma_LoadFile.
The format is CmpDma_GetFileInfo in mm/src/code/sys_cmpdma.c. Offsets in
the table are relative to dataStart, and sub-file 0 is implicit.

The container itself is stored uncompressed, so it carries no magic and
GetCompressionType cannot see it -- torch read the raw table bytes and
every texture in those files decoded to garbage.

Decoding concatenates the sub-files, which is what CmpDma_LoadAllFiles
builds and what the asset offsets already address, so AutoDecode indexes
it exactly like any other whole-file decode.

Opt-in only, via `compression: CMPDMA` in a file's :config:. A container
starts with its dataStart word (0000003C for icon_item_24_static_yar),
which is plausible leading data for an unrelated file, so sniffing it
would be unsafe. Being explicit also keeps this additive: no existing
yaml sets the key, so OoT extraction is untouched.

Takes MM texture extraction from 9495/10014 to 10014/10014 byte-identical
against the OTRExporter reference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Majora's Mask runs on the same engine as Ocarina of Time and declares
the same asset formats, so MM: type names resolve to the OoT factories.
Which ones actually produce byte-identical output is now measurable per
type; that is the input to deciding what moves to a shared zelda64
namespace and what needs a real MM implementation.

OoTDListHelpers hardcoded OOT:ARRAY and OOT:MTX when resolving
references out of a display list. That code is shared, so it now accepts
either prefix.

ExportMtx could not simply try each candidate: GetSafeStringByAddr
throws when a node exists with a different type rather than returning
nullopt, so the first probe aborted the run. It resolves the node once
and checks its type against the accepted set instead.

First measurements against the reference:
  MM:ARRAY  85/85    byte-identical
  MM:MTX    11/11    byte-identical
  GFX      610/5666

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The MM reference declares 688 paths and none of them come from XML --
Majora's Mask has no Path element, so every one is recovered from the
reference archive. The OoT path factory serves them once the harness can
supply num_paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MM's pathway list carries two fields OoT treats as padding. The rom
struct is 8 bytes -- numPoints (u8), unk1 (s8), unk2 (s16), pointsAddr
(u32) -- and OTRExporter's PathExporter.cpp writes unk1 and unk2 under a
MM_RETAIL check. Torch read them as three padding bytes and wrote
neither, which is why every failing path was short by an exact multiple
of 3: three bytes per pathway.

Both readers now carry them. The pair<uint8_t,uint32_t> the pathway list
was built from could not hold them, so it becomes a Pathway struct.

Two changes were needed, not one. The second only became visible after
the first: OoT's alternate headers export just the first pathway of a
shared list, and MM exports all of them. Instrumenting showed exactly 19
truncations against exactly 19 remaining failures.

Both are gated on a new `game:` key in the rom config (OOT default, MM),
declared rather than sniffed from the cartridge title -- same reasoning
as the CMPDMA opt-in. Shared code branches on it instead of forking,
following the kArrayTypes precedent in OoTDListHelpers.

  OPTH  166/688 -> 688/688
  total 47622 -> 48310 pass, 1600 -> 1078 fail

OoT gate: 35386 matching, 0 mismatched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five separate divergences, each found by walking a failing asset against
OTRExporter's RoomExporter switch rather than by reading ahead.

SetRoomBehavior: OoT writes gameplayFlags then gameplayFlags2 whole; MM
unpacks that word into the five fields it encodes, six bytes against
OoT's five. This alone was the "ours is one byte short" that every one of
the 414 failing rooms showed.

Room names: MM zero-pads the index, _room_00 not _room_0, which is also
how its XML names the files.

0x19: MM has SetWorldMapVisited where OoT has SetCameraSettings, and MM
writes no body at all. Torch was writing OoT's five bytes -- a reuse
rather than an addition, so nothing warned.

0x1A/0x1B/0x1C/0x1E: SetAnimatedMaterialList, SetActorCutsceneList,
SetMinimapList, SetMinimapChests do not exist in OoT. SetMinimapList has
no count of its own and takes one entry per room, so SetRoomList now
records the room count in the write context.

SetCutscenes: MM carries a list of entries where OoT carries one pointer,
and ZAPD rewrites the opcode to 0x1F when exporting it. Entries name
their cutscene off the scene's base name, so an alternate header's
cutscenes still belong to the scene rather than to the header.

  OROM   595 -> 21 failing
  EXTRA  132 -> 24
  total  48310 -> 48884 pass

OCVT rises 17 -> 294, which is not a regression: those cutscenes had no
correct name before, so they were extras or missing rather than compared.
They are now correctly named with OoT-format contents, which makes MM's
cutscene command set the next piece of work.

OoT gate: 35386 matching, 0 mismatched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A cutscene that is declared somewhere already has a name; only undeclared
ones should get a synthesized one. The MM list writer synthesized
unconditionally, so 21 scenes referenced a name the reference spells
differently -- gTerminaField... against Z2_00KEIKOKUCutsceneData_...

Mirrors what the OoT path already did: resolve the pointer first, fall
back to MakeAssetName.

  OROM   21 -> 13 failing
  EXTRA  24 -> 0

Extras reaching zero means torch no longer emits anything the reference
does not have.

OoT gate: 35386 matching, 0 mismatched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Majora's Mask animated materials, which OoT has no equivalent of -- so a
new MM-only factory rather than a branch inside shared code, and OoT
cannot be affected by construction.

Mirrors OTRExporter's TextureAnimationExporter and ZAPD's
ZTextureAnimation. The entry list is 8 bytes per entry and runs until an
entry whose segment is <= 0, that entry included. Five params shapes
behind it: scrolling rows, three colour-change variants that size their
lists by animLength or colorListCount depending on which, a texture cycle
whose texture list is sized by the largest index that names it, and an
empty form.

Both sources are wired: XML-declared TextureAnimation elements go through
the factory, and scenes discovering an animated material list now
register the serialized companion rather than only writing its name.

Note for anyone adding a factory here: CMake globs the source list at
configure time, so a new .cpp links as an undefined vtable until cmake is
re-run.

  OTAN 0/293 -> 293/293

OoT gate: 35386 matching, 0 mismatched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OoT leaves an unresolvable display list as the raw G_DL with its address
incremented. MM instead rewrites it to G_DL_OTR_INDEX, with the address
replaced by (segment << 24) | (offset / 8) -- an index into that
segment's gfx buffer. See the G_DL case in OTRExporter's
DisplayListExporter, guarded on the segment not being registered.

In practice this is segments 8 through 13, which are runtime-swapped and
which the OoT path deliberately never tries to resolve. A zero segment
offset keeps the plain opcode in both games.

gsSPDisplayListOTRIndex already existed; the branch form did not, so
gsSPBranchListOTRIndex is added alongside it.

  ODLT   438 -> 42 failing
  total  49805 -> 50092 pass  (99.2%)

OoT gate: 35386 matching, 0 mismatched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MM's cutscene command set is separate from OoT's. Per
ZCutscene::GetCommandMM everything funnels into eight shapes: a generic
8-byte entry; 0x0C-byte text, rumble, transition-general, fade-out-seq
and time; a 0x30-byte actor cue (ids 100-149, 201, 450-599 and 300); and
the spline group, whose header list terminates on a 0xFFFF marker and
whose leading count is a byte length rather than an entry count. The
output side mirrors OTRExporter_Cutscene::SaveMM.

The segfault this was parked for was never in this code. While
iterating, one build invocation ran with the wrong working directory and
silently did nothing, so score.sh kept measuring a stale binary from an
earlier broken state. The same source runs clean repeatedly once actually
rebuilt, and the reader bounds checks added while hunting it never fire.
They are kept anyway: they cost nothing and turn a future layout mistake
into a logged skip rather than a crash.

  OCVT   318 -> 244 failing
  total  50092 -> 50166 pass

OoT gate: 35386 matching, 0 mismatched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four of the command constants were written from the enum's ordering
rather than read off it, and three were wrong:

  CS_CMD_PLAYER_CUE        300 -> 200   (0x0C8)
  CS_CMD_TRANSITION_GENERAL 250 -> 155  (0x09B)
  CS_CMD_FADE_OUT_SEQ       254 -> 156  (0x09C)
  CS_CMD_TIME               255 -> 157  (0x09D)

Id 200 is the player cue and carries 0x30-byte entries. Reading it as an
8-byte generic desynced the rom walk by 40 bytes per occurrence, which is
where the runs of impossible "command id 0, zero entries" came from --
those were the middle of an actor cue being read as a command header.

Found by decoding the reference's own tail: it had a command 200 whose
entry was plainly 8 bytes where this code wanted 48, which pointed
straight at the constant rather than at the layout.

  OCVT   244 -> 0 failing
  total  50166 -> 50393 pass  (99.8%)

OoT gate: 35386 matching, 0 mismatched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ExportVtx looked the vertex up with GetNodeByAddr(ptr), where ptr had
already been through PatchVirtualAddr. PatchVirtualAddr is
ResolveVirtualAddr, and GetNodeByAddr resolves again, so a file with a
`virtual:` mapping had its vram base subtracted twice. A correct address
became a miss, and the miss fell through to the unresolved-
virtual-segment path, which writes the OTR opcode with zeroed operands
and no hash.

Instrumenting said it plainly: w1=0x801BA550 resolved to ptr=0x80114A90,
exactly where the vertex array is registered, followed by "direct lookup
miss". The fix is to look up the unpatched address.

Found only after a wrong guess -- that BaseAddress corresponds to
RangeStart rather than to file offset 0, so the virtual base needed
RangeStart subtracted. That broke overlays immediately and was reverted;
BaseAddress is the vram address of offset 0 and RangeStart merely bounds
what to extract.

  ODLT  6 -> 0 failing; display lists are now complete
  total 50432 -> 50438 pass

OoT gate: 35386 matching, 0 mismatched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rom struct is 14 bytes with a pad after type -- type @0, x @2, y @4,
z @6, r @8, g @9, b @10, drawGlow @11, radius @12, per LightInfo in
ZAPD's SetLightList.cpp. Torch read the fields back to back, so every
field after type was shifted one byte and only 13 of the 14 were
consumed. The write order was already correct, which is why the failing
scenes were all exactly the right size with a smeared run of bytes.

OoT is unaffected -- it shares the layout and the gate is unchanged at
35386/0, so it either never exercises a light list or exercises it
identically.

  OROM   13 -> 0 failing
  total  50438 -> 50451 pass

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ZAPD's surface type loop runs highestPolyType + 1 times unconditionally
(ZCollision.cpp), so there is always at least one -- including when the
header declares no polygons, and including when polyTypeDefAddress is
null, in which case it reads from segment offset 0.

Both cases occur in MM. object_fu_kaiten_Colheader_0037F8 has zero
polygons and a null polyTypeDef, and the reference's surface type is the
first eight bytes of the object file read as two big-endian words --
002A0014 and 00130000, written back as data[1] then data[0]. Torch
guarded on a non-empty polygon list and then on a non-null address, so it
wrote an empty list and came up exactly eight bytes short.

  OCOL   4 -> 0 failing
  total  50451 -> 50455 pass

OoT gate: 35386 matching, 0 mismatched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Majora's Mask keyframe skeletons and animations, which OoT has neither of
-- a new MM-only factory rather than a branch in shared code, so OoT
cannot be affected by construction. Mirrors CKeyFrameExporter.cpp and
ZAPD's ZCKeyFrame / ZCKeyFrameAnim.

The animation is sized entirely by its skeleton, which is why the
converter now carries the XML's `Skel` attribute through as skel_offset.
Each limb's bit flags say which channels are animated: a set bit spends a
kfNum, a clear one spends a preset value, over six bits of a byte for a
standard skeleton and nine bits of a halfword for a flex one. The
keyframe count is then the sum of the kfNums.

Limb entries are 0xC bytes standard, 0x8 flex, and the flex form writes
callbackIndex where the standard one writes a Vec3s translation.

  OKFA/OKFS  0/12 -> 12/12
  total      50455 -> 50467 pass

OoT gate: 35386 matching, 0 mismatched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sample data buffer was sized min(0x500000, rest of rom). OoT's
Audiotable fits under that; MM's is 0x548770, so everything past the cut
got a header with no sample data at all -- the write is guarded on the
data fitting in the buffer, and silently skipped when it does not.

Instrumenting that guard printed it directly: offset=0x4FD5B0
size=0x2ACA end=0x50007A tableSize=0x500000, twenty times, for exactly
the twenty failing samples. The other 662 sat below the cut.

Every read into the buffer is bounds-checked, so the cap is not load
bearing and the rest of the rom is safe to take.

  OSMP   662/682 -> 682/682
  total  50467 -> 50487 pass, 4 failing

OoT gate: 35386 matching, 0 mismatched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A skeleton header's limb count and its limb table's length are separate
values and can disagree. object_fsn's header claims 18 where only 17
limbs exist -- its xml comments on exactly that. ZAPD writes the header's
count as-is but walks the table's, so torch now takes the table length
from an optional limb_table_count while leaving the written count alone.

Getting that split wrong the first time was visible immediately: applying
the override to both made the length correct but wrote 17 in the header
where the reference writes 18.

  gFsnSkel fixed; total 50487 -> 50488 pass

OoT gate: 35386 matching, 0 mismatched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An array of bare scalars. Each element repeats its own type tag before
its value, which is what ArrayExporter writes -- 80 elements of
(u32 type 3, u8 value) for this one, ZSCALAR_X8.

The two other unsupported array kinds are deliberately not implemented;
see the harness commit for why.

  total 50488 -> 50489 pass

OoT gate: 35386 matching, 0 mismatched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ZAPD emits object_horse_link_child's skin-limb display list twice: once as
object_horse_link_child_DL_00D500 from the XML, and again as
object_horse_link_child_Skinlimb_00A138SkinLimbDL_00D500 from the skin-limb
exporter. The two archive entries are byte-identical, self-hash included --
the copy carries CRC64 of the *original's* path, and the limb that references
offset 0xD500 resolves to the original name, not the copy's.

A plain second declaration at the same offset gets neither right: it hashes
itself under its own name, and being registered later it takes over the
address map, so the limb starts pointing at the copy.

`duplicate_of: <path>` on a node says both. The node is skipped when the
address map is populated, and the display list exporter hashes the named path
instead of its own.

MM 50489 -> 50490 of 50496. OoT pal_gc 35386 matching, 0 mismatched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MM declares three arrays whose elements are neither vertices nor vectors:
two CollisionPoly arrays in ovl_Dm_Char08 and a Pointer array in
object_hanareyama_obj. OTRExporter has no writer for either kind, so it emits
the array header and then one bare type word per element with no payload --
the whole body is determined by the count.

SohArrayType mirrors ZAPD's ZResourceType, so CollisionPoly is 28 and Pointer
29, which is what the reference's header words say.

The reference archive currently has uninitialized memory in those element
words; briaguya0/OTRExporter deterministic-arrays fixes that at the source.
Against a reference built with it these three match; against the current one
they differ only in the words that fix zeroes.

MM: all 50496 assets now generate. OoT pal_gc 35386 matching, 0 mismatched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MM's sun is one 64x64 texture the rom stores in pieces, which ZAPD cannot
extract whole -- gameplay_keep.xml says exactly that above gSunSunsetTex. ZAPD
compensates by rewriting the t coordinate of every vertex the display list
named gSunDL loads (ZDisplayList.cpp, GfxdCallback_Vtx):

    if (self->GetName() == "gSunDL")
        vtx.t = (((vtx.t >> 5) - 1) / 2) << 5;

so the exported array deliberately does not match the rom: t 992 (31 texels)
becomes 480 (15), and 512 (16) becomes 224 (7). Reproduced verbatim, integer
division and all, behind a `sun_tc` node flag the harness sets by applying
ZAPD's own rule -- walk the display list named gSunDL, mark the arrays it
loads.

gameplay_keepVtx_07ACF8 was the last non-text mismatch. MM 50493 -> 50494 of
50496. OoT pal_gc 35386 matching, 0 mismatched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MM:TEXT was registered to OoTTextFactory, which writes OoT's format -- hence
86637 bytes against the reference's 448796. MM's differs throughout: the table
entry holds the message offset at +4 with the segment in the top byte, each
message carries its own 11-byte header (textboxType, textboxYPos, icon,
nextMessageID, firstItemCost, secondItemCost), the terminator is 0xBF rather
than 0x02, and the codes taking arguments are a different set (0x14 one byte;
0x1B-0x1F two).

Layout and control codes from ZAPDTR/ZAPD/ZTextMM.cpp (ZTextMM::ParseMM); field
order from OTRExporter/TextMMExporter.cpp.

staff_message_data_static is its own format again -- no header, ends at 0x02,
its own control codes -- selected by the file's name, as ZAPD selects it. Its
textboxYPos keeps the low nibble of the table's type/position byte while
textboxType is zeroed immediately after being computed; that asymmetry looks
like an oversight but it is what the reference contains, so it is reproduced.

MM 50494 -> 50496 of 50496, byte-identical. OoT pal_gc 35386 matching, 0
mismatched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants