Skip to content

Twelve more Enemy subclasses -- and two that turned out not to be - #1389

Merged
andrewboudreau merged 1 commit into
mainfrom
cpp/enemy-subclasses-3
Aug 11, 2026
Merged

Twelve more Enemy subclasses -- and two that turned out not to be#1389
andrewboudreau merged 1 commit into
mainfrom
cpp/enemy-subclasses-3

Conversation

@andrewboudreau

Copy link
Copy Markdown
Collaborator

Twelve Enemy subclasses become real C++: LavaBubble, WaterRing (ov064),
LakituBro, Rabbit, RabbitKey (ov085), MantaRay, Skeeter (ov090),
Whomp (ov079), Key (ov089), WaterBomb (ov098), RollingIronBall
(ov100), PowerStar (ov002).

It was meant to be fourteen. Bully and BigBully are not Enemy subclasses, and
three independent sources say so:

  • their destructors store two vtables. Deleting either store makes the
    function shrink, so the ROM really has both — the second is an inlined base
    destructor's vptr write, and both classes store the same second vtable,
    which only makes sense if it is their shared base's.
  • that vtable is data_ov064_0211b768; its slots 16/17 are
    func_ov064_02115ee0 / func_ov064_02115f28, an unnamed D1/D0 pair differing
    by 0x14 — exactly a deallocation.
  • the RTTI graph names the base: daDonketu_c and daBDonketu_c both derive
    from daOts_c, never from dEnemyBase_c.

struct Bully : Enemy emits one vptr store and cannot match. That intermediate
wants recovering the way CapEnemy was; it is not this PR.

The factory is the second witness, and it outranks the field span

Every class here has two witnesses that had to agree before it was touched: the
destructor destroys the members, and <Class>_Spawn constructs the same types at
the same offsets. All twelve agreed.

Six of the fourteen sizes were wrong when taken from the span of the evidenced
fields. Each _Spawn calls ActorBase::operator new with a literal, and that
literal is the class's sizeof; the field span is only a lower bound. MantaRay's
fields reach 0x38c and it allocates 0x404 — 0x94 of trailing space no source reads.

The factory is also the only witness to a member with a trivial destructor:
RollingIronBall constructs a PathPtr at 0x3f4 that nothing destroys. 0x3f4 + 8
is 0x3fc, exactly the allocation literal — the layout does not close without it.

Two substitutions were semantic changes, and the byte gate caught both

The sources spelled *(char**)&unk_30c + 0x20, meaning 0x20 bytes. Typing the
member turns the same text into data.materials + 0x20 — pointer arithmetic
scaled by sizeof(BMD_Material). Rabbit's materials and Key's bones both had it.

Actor declares param1 u32, but Rabbit::InitResources shifts it with ASR, not
LSR — the ROM reads it signed there, and without the casts the function came out
two words different. The flat header called 0x008 an s32, which is exactly why
the rebase exposed it and nothing before it could.

Results

  • 79 of 80 functions across the twelve classes reproduce.
  • 12 headers pass check_header_offsets with 0 mismatched, each struct spanning
    exactly its asserted size.
  • rombuild: 106/106 exact, 10,813 reproducing, 0 mismatching.
  • prepush_linkcheck: 80 checked, 78 verified, 0 blocking.
  • prepush_attribution: 0 changed, 0 lost. port_refcheck, check_data_definitions,
    check_duplicate_sources: clean. Eligible name list unchanged from baseline.

WaterBomb::InitResources no longer casts this to a shadow struct; the tail
fields that shadow was the only record of (0x3a8..0x3b6) moved into the header.

Whomp::InitResources now compiles and byte-matches — on main it did not compile
at all. It is still not eligible, and I did not establish why: its object has one
.text of 0x424 (exactly the declared size), one global FUNC whose st_size
matches, and no rodata, which is what that gate asks for. Two runs agree it is
excluded. Unexplained, not diagnosed.

Known, and pre-existing

RollingIronBall::InitResources reports a size mismatch. This is not a
regression
— it fails identically on 1b45f57b, before this branch, where the
class was still flat. Its body still carries laundering hacks and a volatile
read; it wants its own matching session.

Noted, evidenced, not fixed

TextureSequence::Prepare is declared a non-static member, but the ROM passes it
two argument registers and no this — LakituBro's call site writes r2 after the
bl. All 23 call sites in the tree declare it with two parameters. A mangled name
does not encode staticness, so the symbol is equally a static member's, which
is what the evidence says. Its 0xc body is a pure ldr r12,[pc]; bx r12 tail jump
that reproduces for any signature, so the definition file's this is unconstrained
by the ROM and contradicted by every caller. Changing it touches 23 dependents and
belongs in its own PR.

Intended as fourteen. Bully and BigBully are not Enemy subclasses, and three
independent sources say so:

  - their destructors store TWO vtables, not one. Deleting either store makes
    the function shrink, so the ROM really has both -- that second store is an
    inlined base destructor's vptr write, and Bully and BigBully store the SAME
    second vtable, which only makes sense if it is their shared base's.
  - data_ov064_0211b768 is that vtable. Its slots 16/17 are func_ov064_02115ee0
    and func_ov064_02115f28, an unnamed D1/D0 pair differing by 0x14 -- exactly
    a deallocation.
  - the RTTI graph names the base: daDonketu_c and daBDonketu_c both derive
    from daOts_c, never from dEnemyBase_c.

`struct Bully : Enemy` would emit one vptr store and could not match. The
intermediate wants recovering the way CapEnemy was; it is not this commit.

The twelve that are Enemy subclasses each have two witnesses that had to agree
before the class was touched: the destructor destroys the members, and the
factory constructs the same types at the same offsets. Every one agreed.

SIX OF THE FOURTEEN SIZES WERE WRONG, and the factory is why. Each _Spawn calls
ActorBase::operator new with a literal, and that literal IS the class's sizeof;
the span of the evidenced fields is only a lower bound. MantaRay's fields reach
0x38c and it allocates 0x404 -- 0x94 of trailing space no source reads. Same
correction as the seven in the previous commit, from the same cause.

The factory is also the only witness to a member with a trivial destructor:
RollingIronBall constructs a PathPtr at 0x3f4 that nothing destroys. 0x3f4 + 8
is 0x3fc, exactly the allocation literal -- the layout does not close without
it, and reading only the destructor left the class eight bytes short.

TWO SUBSTITUTIONS WERE SEMANTIC CHANGES, and the byte gate caught both. The
sources spelled `*(char**)&unk_30c + 0x20`, meaning 0x20 BYTES; typing the
member makes it `data.materials + 0x20`, which is pointer arithmetic scaled by
sizeof(BMD_Material). Rabbit's materials and Key's bones both had it.

Actor declares param1 u32, but Rabbit::InitResources shifts it with ASR, not
LSR -- the ROM reads it signed there. Without the casts the function came out
two words different. The flat header called 0x008 an s32, which is exactly why
the rebase exposed it and nothing before it could.

WaterBomb's InitResources cast `this` to a full shadow struct; that is gone,
and the tail fields it was the only record of (0x3a8..0x3b6) moved into the
header. Its `+ 0x300 + 0xb4` folds to 0x3b4 -- past mModel's end, so a field of
this class and not something inside the Model.

Whomp::InitResources now compiles and byte-matches; on main it did not compile
at all, because two of its local declarations collided with decl_common.h's.
It is still not ELIGIBLE, so the enrolled count does not move. I did not
establish why: eligible.py gates on whether the object can link in place rather
than on the bytes, but the compiled object has one .text of 0x424 -- exactly the
declared size -- one global FUNC whose st_size matches, and no rodata, which is
what that gate asks for. Two runs agree it is excluded. Unexplained, not
diagnosed.

79 of the 80 functions across the twelve classes reproduce. Twelve headers pass
check_header_offsets with 0 mismatched, each struct spanning exactly its
asserted size. 106/106 exact, 10,813 reproducing, 0 mismatching; the eligible
name list is unchanged from the baseline.

STILL BROKEN, AND WAS BEFORE THIS BRANCH: RollingIronBall::InitResources
reports a size mismatch, reproduced on 1b45f57 before any of this. Its body
still carries laundering hacks and a volatile read; it wants its own session.

NOTED, NOT FIXED: TextureSequence::Prepare is declared a non-static member, but
the ROM passes it two argument registers and no `this` -- LakituBro's call site
writes r2 AFTER the bl. All 23 call sites in the tree declare it with two
parameters. A mangled name does not encode staticness, so the symbol is equally
the mangling of a static member, which is what the evidence says it is. Its 0xc
body is a pure `ldr r12,[pc]; bx r12` tail jump that reproduces for any
signature, so the definition file's `this` is unconstrained by the ROM and
contradicted by every caller. Changing it touches 23 dependents and belongs in
its own commit.
@tangos-validator

tangos-validator Bot commented Aug 11, 2026

Copy link
Copy Markdown

✅ PR validation — Passed

Committed merge introduces no reconstruction or attribution regression.

Full merge validation

Check Result
Committed test merge yes
Matched functions 11,178 / 11,347 (98.5%, +0)
Matched code bytes 2,078,800 / 2,211,124 (94.0%, +0)
Tracked source enrollment 10,785 functions, 1,936,196 bytes (87.57%, +0)
Perfect source moves 0 R100
Contributor credit 0 added, 0 changed, 0 lost
Relocation check 80 checked; 2 BLIND, 78 VERIFIED
Port reference check 393 checked; 0 stale
Module fidelity 106/106 exact; 100.000000% compared bytes
Code linked from verified source 10,813 functions, 1,943,084 bytes (87.88%)

Warnings: 2 linkcheck result(s) have unresolved relocations.

Per-file link-check detail

All 80 changed file(s) compile to the ROM byte-for-byte with correct relocation targets.

File Symbol Result Slots checked
src/_ZN10LavaBubble13InitResourcesEv.cpp _ZN10LavaBubble13InitResourcesEv ✅ verified 1
src/_ZN10LavaBubble16CleanupResourcesEv.cpp _ZN10LavaBubble16CleanupResourcesEv ✅ verified 1
src/_ZN10LavaBubble16OnPendingDestroyEv.cpp _ZN10LavaBubble16OnPendingDestroyEv ✅ verified 1
src/_ZN10LavaBubble6RenderEv.cpp _ZN10LavaBubble6RenderEv ✅ verified 1
src/_ZN10LavaBubble8BehaviorEv.cpp _ZN10LavaBubble8BehaviorEv ✅ verified 1
src/_ZN10LavaBubbleD0Ev.cpp _ZN10LavaBubbleD0Ev ✅ verified 1
src/_ZN10LavaBubbleD1Ev.cpp _ZN10LavaBubbleD1Ev ✅ verified 1
src/_ZN15RollingIronBall13InitResourcesEv.cpp _ZN15RollingIronBall13InitResourcesEv 🔶 blind (a reloc slot could not be resolved) 1
src/_ZN15RollingIronBall16CleanupResourcesEv.cpp _ZN15RollingIronBall16CleanupResourcesEv ✅ verified 1
src/_ZN15RollingIronBall6RenderEv.cpp _ZN15RollingIronBall6RenderEv ✅ verified 1
src/_ZN15RollingIronBall8BehaviorEv.cpp _ZN15RollingIronBall8BehaviorEv ✅ verified 1
src/_ZN15RollingIronBallD0Ev.cpp _ZN15RollingIronBallD0Ev ✅ verified 1
src/_ZN15RollingIronBallD1Ev.cpp _ZN15RollingIronBallD1Ev ✅ verified 1
src/_ZN3Key13InitResourcesEv.cpp _ZN3Key13InitResourcesEv ✅ verified 1
src/_ZN3Key16CleanupResourcesEv.cpp _ZN3Key16CleanupResourcesEv ✅ verified 1
src/_ZN3Key6RenderEv.cpp _ZN3Key6RenderEv ✅ verified 1
src/_ZN3Key8BehaviorEv.cpp _ZN3Key8BehaviorEv ✅ verified 1
src/_ZN3KeyD0Ev.cpp _ZN3KeyD0Ev ✅ verified 1
src/_ZN3KeyD1Ev.cpp _ZN3KeyD1Ev ✅ verified 1
src/_ZN5Whomp13InitResourcesEv.cpp _ZN5Whomp13InitResourcesEv 🔶 blind (a reloc slot could not be resolved) 1
src/_ZN5Whomp16CleanupResourcesEv.cpp _ZN5Whomp16CleanupResourcesEv ✅ verified 1
src/_ZN5Whomp6RenderEv.cpp _ZN5Whomp6RenderEv ✅ verified 1
src/_ZN5Whomp8BehaviorEv.cpp _ZN5Whomp8BehaviorEv ✅ verified 1
src/_ZN5WhompD0Ev.cpp _ZN5WhompD0Ev ✅ verified 1
src/_ZN5WhompD1Ev.cpp _ZN5WhompD1Ev ✅ verified 1
src/_ZN6Rabbit13InitResourcesEv.cpp _ZN6Rabbit13InitResourcesEv ✅ verified 1
src/_ZN6Rabbit16CleanupResourcesEv.cpp _ZN6Rabbit16CleanupResourcesEv ✅ verified 1
src/_ZN6Rabbit16OnPendingDestroyEv.cpp _ZN6Rabbit16OnPendingDestroyEv ✅ verified 1
src/_ZN6Rabbit6RenderEv.cpp _ZN6Rabbit6RenderEv ✅ verified 1
src/_ZN6Rabbit8BehaviorEv.cpp _ZN6Rabbit8BehaviorEv ✅ verified 1
src/_ZN6RabbitD0Ev.cpp _ZN6RabbitD0Ev ✅ verified 1
src/_ZN6RabbitD1Ev.cpp _ZN6RabbitD1Ev ✅ verified 1
src/_ZN7Skeeter13InitResourcesEv.cpp _ZN7Skeeter13InitResourcesEv ✅ verified 1
src/_ZN7Skeeter16CleanupResourcesEv.cpp _ZN7Skeeter16CleanupResourcesEv ✅ verified 1
src/_ZN7Skeeter16OnPendingDestroyEv.cpp _ZN7Skeeter16OnPendingDestroyEv ✅ verified 1
src/_ZN7Skeeter6RenderEv.cpp _ZN7Skeeter6RenderEv ✅ verified 1
src/_ZN7Skeeter8BehaviorEv.cpp _ZN7Skeeter8BehaviorEv ✅ verified 1
src/_ZN7SkeeterD0Ev.cpp _ZN7SkeeterD0Ev ✅ verified 1
src/_ZN7SkeeterD1Ev.cpp _ZN7SkeeterD1Ev ✅ verified 1
src/_ZN8MantaRay13InitResourcesEv.cpp _ZN8MantaRay13InitResourcesEv ✅ verified 1
src/_ZN8MantaRay16CleanupResourcesEv.cpp _ZN8MantaRay16CleanupResourcesEv ✅ verified 1
src/_ZN8MantaRay16OnPendingDestroyEv.cpp _ZN8MantaRay16OnPendingDestroyEv ✅ verified 1
src/_ZN8MantaRay6RenderEv.cpp _ZN8MantaRay6RenderEv ✅ verified 1
src/_ZN8MantaRay8BehaviorEv.cpp _ZN8MantaRay8BehaviorEv ✅ verified 1
src/_ZN8MantaRayD0Ev.cpp _ZN8MantaRayD0Ev ✅ verified 1
src/_ZN8MantaRayD1Ev.cpp _ZN8MantaRayD1Ev ✅ verified 1
src/_ZN9LakituBro13InitResourcesEv.cpp _ZN9LakituBro13InitResourcesEv ✅ verified 1
src/_ZN9LakituBro16CleanupResourcesEv.cpp _ZN9LakituBro16CleanupResourcesEv ✅ verified 1
src/_ZN9LakituBro16OnPendingDestroyEv.cpp _ZN9LakituBro16OnPendingDestroyEv ✅ verified 1
src/_ZN9LakituBro6RenderEv.cpp _ZN9LakituBro6RenderEv ✅ verified 1
src/_ZN9LakituBro8BehaviorEv.cpp _ZN9LakituBro8BehaviorEv ✅ verified 1
src/_ZN9LakituBroD0Ev.cpp _ZN9LakituBroD0Ev ✅ verified 1
src/_ZN9LakituBroD1Ev.cpp _ZN9LakituBroD1Ev ✅ verified 1
src/_ZN9PowerStar13AddStarMarkerEv.cpp _ZN9PowerStar13AddStarMarkerEv ✅ verified 1
src/_ZN9PowerStar13InitResourcesEv.cpp _ZN9PowerStar13InitResourcesEv ✅ verified 1
src/_ZN9PowerStar16CleanupResourcesEv.cpp _ZN9PowerStar16CleanupResourcesEv ✅ verified 1
src/_ZN9PowerStar6RenderEv.cpp _ZN9PowerStar6RenderEv ✅ verified 1
src/_ZN9PowerStar8BehaviorEv.cpp _ZN9PowerStar8BehaviorEv ✅ verified 1
src/_ZN9PowerStarD0Ev.cpp _ZN9PowerStarD0Ev ✅ verified 1
src/_ZN9PowerStarD1Ev.cpp _ZN9PowerStarD1Ev ✅ verified 1
src/_ZN9RabbitKey13InitResourcesEv.cpp _ZN9RabbitKey13InitResourcesEv ✅ verified 1
src/_ZN9RabbitKey16CleanupResourcesEv.cpp _ZN9RabbitKey16CleanupResourcesEv ✅ verified 1
src/_ZN9RabbitKey16OnPendingDestroyEv.cpp _ZN9RabbitKey16OnPendingDestroyEv ✅ verified 1
src/_ZN9RabbitKey6RenderEv.cpp _ZN9RabbitKey6RenderEv ✅ verified 1
src/_ZN9RabbitKey8BehaviorEv.cpp _ZN9RabbitKey8BehaviorEv ✅ verified 1
src/_ZN9RabbitKeyD0Ev.cpp _ZN9RabbitKeyD0Ev ✅ verified 1
src/_ZN9RabbitKeyD1Ev.cpp _ZN9RabbitKeyD1Ev ✅ verified 1
src/_ZN9WaterBomb13InitResourcesEv.cpp _ZN9WaterBomb13InitResourcesEv ✅ verified 1
src/_ZN9WaterBomb16CleanupResourcesEv.cpp _ZN9WaterBomb16CleanupResourcesEv ✅ verified 1
src/_ZN9WaterBomb6RenderEv.cpp _ZN9WaterBomb6RenderEv ✅ verified 1
src/_ZN9WaterBomb8BehaviorEv.cpp _ZN9WaterBomb8BehaviorEv ✅ verified 1
src/_ZN9WaterBombD0Ev.cpp _ZN9WaterBombD0Ev ✅ verified 1
src/_ZN9WaterBombD1Ev.cpp _ZN9WaterBombD1Ev ✅ verified 1
src/_ZN9WaterRing13InitResourcesEv.cpp _ZN9WaterRing13InitResourcesEv ✅ verified 1
src/_ZN9WaterRing16CleanupResourcesEv.cpp _ZN9WaterRing16CleanupResourcesEv ✅ verified 1
src/_ZN9WaterRing16OnPendingDestroyEv.cpp _ZN9WaterRing16OnPendingDestroyEv ✅ verified 1
src/_ZN9WaterRing6RenderEv.cpp _ZN9WaterRing6RenderEv ✅ verified 1
src/_ZN9WaterRing8BehaviorEv.cpp _ZN9WaterRing8BehaviorEv ✅ verified 1
src/_ZN9WaterRingD0Ev.cpp _ZN9WaterRingD0Ev ✅ verified 1
src/_ZN9WaterRingD1Ev.cpp _ZN9WaterRingD1Ev ✅ verified 1

The private worker commits a test merge, builds the stock ROM profile, compares every executable module, measures matched and source-built code, checks contributor lineage, and verifies affected relocations. The mod profile is opt-in and is not part of this merge gate.

@andrewboudreau
andrewboudreau merged commit 57d6c0f into main Aug 11, 2026
3 checks passed
@andrewboudreau
andrewboudreau deleted the cpp/enemy-subclasses-3 branch August 11, 2026 05:30
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.

1 participant