Skip to content

PoleLift: define the destructor in the class body (and why promotion stops at the coined name) - #2066

Merged
andrewboudreau merged 1 commit into
mainfrom
tu/polelift-inline-dtor
Aug 31, 2026
Merged

PoleLift: define the destructor in the class body (and why promotion stops at the coined name)#2066
andrewboudreau merged 1 commit into
mainfrom
tu/polelift-inline-dtor

Conversation

@andrewboudreau

Copy link
Copy Markdown
Collaborator

Defines ~PoleLift() in the class body. This is the prerequisite flip for promoting
ov045/PoleLift to a genuine TU. It does not promote the TU — I attempted the
promotion, measured a hard refusal that only a class rename can clear, and backed it
out. That measurement is the second half of this PR, recorded in the manifest.

Base: a00c09854 (#2063). No ledger file is touched — no config/converted-baseline.json,
no attribution.json, no delinks.txt, no symbols.txt — so this PR is not in the
ledger queue
and does not contend with a promotion PR.

The flip

An out-of-line ~PoleLift() makes mwccarm 2004/b56 emit D2, D0, D1. The cartridge
holds D1, D0 and no D2. Defined in the class body, the compiler emits exactly the
ROM's two variants in the ROM's order:

before   ov045/PoleLift: D0, D1 out of order, plus a homeless _ZN8PoleLiftD2Ev
after    ov045/PoleLift: ALL MATCH, ROM order, nothing unlicensed

The two per-symbol sources are rewritten as forcing helpers, not deleted, so there
is no unbuildable intermediate state: an explicit p->~PoleLift() forces the
out-of-line D1 copy, a delete-expression forces D0, and objisolate keeps the symbol
each file is bound to. This mirrors the already-landed ov029/ArrowLift shape.

Byte proof — before and after, both on this base

before after
module fidelity 106/106 exact, 100.000000% 106/106 exact, 100.000000%
source-built functions 11,088 11,088
reproducing / mismatching 11,088 / 0 11,088 / 0
source-owned data claims 1 repro / 0 mismatch 1 repro / 0 mismatch
ROM-build analysis PASS PASS
full-ROM sha256 d1506e90...c478e8 d1506e90...c478e8 (stock)
eligible.py 11096 / 11211 11096 / 11211
port_refcheck.py 405 checked, 0 stale 405 checked, 0 stale
langmode_audit --check PASS PASS
tiers_ratchet 2568 CONVERTED, 0 backslid 2568 CONVERTED, 0 backslid
ROM-data object records 7,636 7,625

The -11 records, per symbol

Not a coverage loss. Per-record --data-json diff on both trees: 1,245 distinct
symbols before and after
, and an identical verdict tally of 462 VERIFIED / 774
PARTIAL / 9 DIFFERS
. No symbol is lost and no verdict changes.

It is de-duplication. Before the flip, ~PoleLift() was the key function and both
destructor files defined it, so each emitted the whole vtable/RTTI group. Inline, the
key function becomes Behavior() — the first declared non-inline virtual — which
exactly one file defines. _ZTV8PoleLift stays VERIFIED, re-attributed from
src/_ZN8PoleLiftD1Ev.cpp to src/_ZN8PoleLift8BehaviorEv.cpp. Every dropped record is
a second copy of one that survives.

Applying #2061's discriminator: no data_* entry is dropped here at all, keepable or
otherwise, so neither the gate-silencing nor the unkeepable case applies.

Why the promotion is not in this PR

I ran tu_promote.py ov045/PoleLift and built it. The D2 blocker is discharged
that part of the census prediction is now compiled rather than inferred. What replaces
it is the class's own RTTI.

A consolidated object takes objisolate.derive_deadstrip, which admits no unlicensed
non-.text content. Defining the key function, the TU emits eleven such records. Nine
resolve to a configured ROM home and take a deadstrip-data row; those are accepted.
Two do not, and all four available routes refuse:

route measured verdict
omit the rows isolate: unlicensed content in text-only multi-symbol object: section[10] .data size 0xa defines ['_ZTS8PoleLift']; section[17] .data size 0xc defines ['_ZTI8PoleLift']
deadstrip-data ...is declared compiler-only data but has no configured ROM home; a homeless object is a plain deadstrip
deadstrip ...is an RTTI/vtable record banked as a plain deadstrip, which is never compared against the cartridge
production_mode: intact-object no byte-identical emission exists — see below

The cause is a coined class name, and the cartridge says so directly. Read out of
build/build/arm9_ov045.bin:

  • the vtable's typeinfo word at 0x02112db8 reads 0x02112d74;
  • 0x02112d74 is _ZTI18daObjKm2_Ami_Bou_c, whose name pointer is 0x02112d80 and
    whose base pointer is 0x021089ec = _ZTI10dBgActor_c — the base this header already
    declares, so the hierarchy is right;
  • 0x02112d80 holds the literal ASCII 18daObjKm2_Ami_Bou_c.

An _ZTS record is a length-prefixed mangled string, so the compiler's 8PoleLift
(10 bytes) can never match the cartridge's 18daObjKm2_Ami_Bou_c (21 bytes) — under any
production mode. The gate's own remedy is the right one: rename this class to
daObjKm2_Ami_Bou_c
, which the ROM evidences at 0x02112d74/0x02112d80. That is a
separate PR with its own rename procedure, and deliberately not done here.

The manifest entry now records all of this: unlicensed_output_observed is re-measured
post-flip (.text list now empty; eleven data records with their homes, or their
absence, spelled out), the verification.criteria rows that named D2 are corrected, and
the notes bullet that said promotion needed "a licensed home for _ZN8PoleLiftD2Ev"
is replaced with the blocker that actually remains.

Conventions checklist

Items 1-4 and 6 concern a promotion's diff. This diff promotes nothing, renames nothing,
deletes no file and touches no ledger, so they are vacuous rather than passed — with two
exceptions worth checking:

  1. Coined mangled names — no symbol is coined or renamed here. Note for the record
    that PoleLift is itself a coined class name; this PR does not act on that, it
    documents it in the manifest with the ROM addresses that settle it.
  2. extern int _ZTV<C>[]; — not applicable while unpromoted, and flagged as
    outstanding work: src_tu/actors/PoleLift.cpp currently spells the store
    p[0] = (int)(_ZTV8PoleLift + 2);, where the convention asks for
    *(int *)p = (int)&_ZTV8PoleLift[2];. Same arithmetic, different spelling; left
    alone here to keep this diff byte-neutral and in scope.
  3. _ZTV address unchanged / complete span_ZTV8PoleLift remains
    0x02112dbc in config/arm9/overlays/ov045/symbols.txt; this PR does not touch that
    file. No complete span is added or widened — delinks.txt is untouched, so ov045
    keeps its seven per-function entries. No data_* or bss entry is dropped.
  4. Stale func_<module>_<addr> declarations — nothing renamed, so nothing to
    remove; include/decl_common.h is untouched.
  5. No comment names a file the diff deletes — the diff deletes no file. Two comments
    my change invalidated are corrected: src_tu/actors/PoleLift.cpp's note about the
    D2 section, and include/PoleLift.h's claim that the flat-C twin exists because "the
    D0 file is a C translation unit that reads these fields". Every includer of that
    header is C++ today, and D0 now names no field; the twin is left in place, only its
    justification is corrected.
  6. Ledger queue — this PR carries no ledger file, so it neither waits on nor blocks
    the queue. TU: first compiler-built vtable — promote ov047/daObjKm3_Kurumajiku_c to intact-object production #2057 merged before it was opened.

Gates run

rombuild -j16 (full ROM), eligible.py, port_refcheck.py, langmode_audit --check,
tiers_ratchet.py, tu_order_check.py, check_src_tu.py, check_src_tu_compiles.py
(92/92), check_header_offsets.py include/PoleLift.h (2 commented fields, 0 mismatched),
check_dead_references.py, check_references.py, check_duplicate_sources.py,
check_data_definitions.py. All pass.

twin_align.py --check FAILs — identically, on the same two headers, on unmodified
origin/main. PoleLift.h is not among them. Pre-existing and untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WdCK1xgrJdiJzPCh3bAJfQ

An out-of-line ~PoleLift() makes mwccarm 2004/b56 emit three variants --
D2, D0, D1 -- and the cartridge holds D1 and D0 with no D2 at all. Defining
the destructor in the class body emits exactly the ROM's two, in the ROM's
order. tools/tu_order_check.py ov045/PoleLift now reports
"ALL MATCH, ROM order, nothing unlicensed"; before this it reported a
homeless D2 and an out-of-order ordinal pair.

The two per-symbol sources are rewritten as forcing helpers rather than
deleted, so no commit in between is unbuildable: an explicit p->~PoleLift()
forces the out-of-line D1 copy and a delete-expression forces D0, and
objisolate keeps the symbol each file is bound to.

Byte-neutral, measured: 106/106 modules exact, 11,088 reproducing / 0
mismatching, ROM-build analysis PASS, and the built ROM is sha256-identical
to stock. ROM-data object records fall 7,636 -> 7,625 with no symbol and no
verdict lost -- 1,245 distinct symbols and 462 VERIFIED / 774 PARTIAL /
9 DIFFERS before and after. That is de-duplication: the key function moves
from ~PoleLift(), which both destructor files defined, to Behavior(), which
exactly one file defines. _ZTV8PoleLift stays VERIFIED.

This does NOT promote the TU. The manifest records why, re-measured: the
D2 blocker is discharged, and what remains is that the consolidated object
emits _ZTI8PoleLift and _ZTS8PoleLift, for which no compiler_only_output
disposition is admissible while the class carries a coined name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdCK1xgrJdiJzPCh3bAJfQ
@tangos-validator

tangos-validator Bot commented Aug 31, 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
Byte-verified functions 10,901 / 11,347 (96.07%, +0)
Byte-verified code bytes 2,033,432 / 2,211,124 (91.96%, +0)
Claimed, not byte-verified 316 functions, 72,248 bytes (+0)
Perfect source moves 0 R100
Enrolled ranges (delinks complete) 11,059 functions, 2,053,148 bytes (92.86%, +0) -- differs from byte-verified by +158
Contributor credit 0 added, 0 changed, 0 lost
Relocation check 6 checked; 6 VERIFIED
Port reference check 405 checked; 0 stale
Module fidelity 106/106 exact; 100.000000% compared bytes
Code linked from verified source 11,088 functions, 2,067,148 bytes (93.49%)
Module bytes from source 2,067,148 / 3,049,600 (67.8%); 811,492 (26.6%) are data no delink entry reaches
ROM data reproduced from source 462 symbol(s) exact, 250 partial, 9 differ

Byte-verified means the range carries complete in a delinks.txt, so the ROM build compiled it and compared it to the cartridge. The 316 claimed functions have a src/ file named after the symbol with no NONMATCHING banner, and nothing compiles them -- dsd fills their addresses with the ROM's own bytes. Both together are the 11,217 this project calls matched.

Per-file link-check detail

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

File Symbol Result Slots checked
src/_ZN8PoleLift13InitResourcesEv.cpp _ZN8PoleLift13InitResourcesEv ✅ verified 1
src/_ZN8PoleLift16CleanupResourcesEv.cpp _ZN8PoleLift16CleanupResourcesEv ✅ verified 1
src/_ZN8PoleLift6RenderEv.cpp _ZN8PoleLift6RenderEv ✅ verified 1
src/_ZN8PoleLift8BehaviorEv.cpp _ZN8PoleLift8BehaviorEv ✅ verified 1
src/_ZN8PoleLiftD0Ev.cpp _ZN8PoleLiftD0Ev ✅ verified 1
src/_ZN8PoleLiftD1Ev.cpp _ZN8PoleLiftD1Ev ✅ 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

Copy link
Copy Markdown
Collaborator Author

Approving. The negative result is the valuable part, and I reproduced it from the cartridge.

You stopped on a measurement instead of forcing a promotion through, and then shipped the half that stands on its own. That is exactly the right call, and the reasoning survives in the diff where the next person will find it.

I re-derived the blocker independently

I did not take the RTTI reading on trust — a name is the one thing bytes are easiest to over-claim about, and this repo has been burned by a "byte-verified" name before. Read straight out of extracted/dsd/arm9_overlays/ov045.bin at base 0x021111a0:

_ZTV8PoleLift @ 0x02112dbc
  -8 (offset-to-top) = 0x00000000
  -4 (typeinfo ptr)  = 0x02112d74
  TI[0] vptr         = 0x0209a764
  TI[1] name ptr     = 0x02112d80 -> '18daObjKm2_Ami_Bou_c'
  TI[2] base ptr     = 0x021089ec

_ZTS is length-prefixed and the prefix is self-checking: 18 is exactly len("daObjKm2_Ami_Bou_c"). There is no reading of those bytes on which the class is called PoleLift. The cartridge names this class daObjKm2_Ami_Bou_c. Your TI[2] base pointer agrees with the header's hierarchy too.

The exposure is not PoleLift-shaped, it is tree-shaped

Your blocker is that promotion consolidates the RTTI and the _ZTI/_ZTS records for a coined name have no ROM home, so every route refuses. That will recur for every class in the same position, so I measured how many there are. For each _ZTV* symbol in config/arm9/**/symbols.txt as it stands on origin/main, resolve the typeinfo record in its own overlay (falling back to arm9) and read the _ZTS string:

total _ZTV symbols in config    541
  RTTI name AGREES with ours    276
  RTTI name DISAGREES           259
  typeinfo word unreadable        6

Just under half the vtable-bearing classes in the tree are exposed to the wall you hit. The six unreadable are daDemo_c::simpleModel_c, daDemo_c::anmModel_c, daOts_c, daObjMaruta_c, daDsnBase_c, daObjFallBlock_c — I am reporting those as unmeasured, not as agreements.

Then I checked the mechanism against what has already landed rather than only against your one failure. Of the 23 manifests on main with status: promoted, 15 own a _ZTV and 8 do not:

own a vtable, name AGREES with the ROM   15
own a vtable, name DISAGREES              0
own no vtable (RTTI never arises)         8

Zero promoted TUs in the tree carry a name the ROM contradicts. Not one has ever slipped through, which is what I would expect if the constraint is real rather than incidental to PoleLift, and it is a much better argument for your position than the single failure is.

And the wave itself re-scores. Intersecting the 41-TU inline-destructor census against this: 11 of its classes have ROM-agreeing names, 48 disagree, 19 own no vtable. All 11 agreeing ones already have manifests on main. So the flip lands anywhere, but on the promotion side the agreeing half of that census is already spent — the remainder is gated on renames, not on destructor mechanics. That is worth knowing before anyone works the list top-to-bottom expecting 41 promotions out of it.

On the merge itself

Docs and comments aside, the substance is: an out-of-line destructor becomes an inline one, and the two src/ files become forcing wrappers. Full ROM sha256 matches the stock hash, 106/106 exact, 11,088 reproducing and 0 mismatching, eligible and port_refcheck and langmode_audit flat, ratchet 2568 CONVERTED with 0 backslid. The -11 ROM-data records is de-duplication and you verified it the right way — per symbol, 1,245 distinct both sides, tally 462/774/9 unchanged, _ZTV8PoleLift still VERIFIED — rather than from the summary count, which is the mistake that metric invites.

Two small things I checked rather than assumed:

  • Your virtual ~PoleLift() {} is on one line. That matters more than it looks: check_header_offsets.py arms its body-skip only from a brace on the signature line, so the Allman form would have gone UNPARSED and silently suppressed the field walk for the whole header. It does not bite here — PoleLift declares its destructor after its fields, which ends the field list before the brace is read — but it will bite the wave. Documented in docs: brace position on an inline structor is load-bearing for header-offsets #2068.
  • The src_tu/ diff is comments plus the removal of the out-of-line definition. src_tu/ is not in the build, so none of it is gated; it reads correctly.

I am not vouching for the byte figures. No ROM was built by me, your BEFORE numbers were measured on 71153e082 rather than the base, and you said so plainly in the PR — that disclosure is why the rest of the report is worth trusting.

Merging.


Re-gated against current main (01c4d350a2a8, after #2068 landed): merge tree 808f1fb40f09, all eight static gates pass -> pass, nothing goes green -> red.

@andrewboudreau
andrewboudreau merged commit 467bde0 into main Aug 31, 2026
8 checks passed
@andrewboudreau
andrewboudreau deleted the tu/polelift-inline-dtor branch August 31, 2026 04:47
andrewboudreau added a commit that referenced this pull request Aug 31, 2026
A coined class name is not just a readability debt -- it is a hard block on TU
promotion, and it does not announce itself as a naming problem. Promoting a TU
consolidates the RTTI, so the compiler emits _ZTI/_ZTS under our name while the
cartridge holds those records under the real one, at a different length. There
is no ROM symbol to license them to and tubuild refuses with HOMELESS/COLLIDES-GAP.

#2066 found this the expensive way: PoleLift is daObjKm2_Ami_Bou_c in the ROM,
so that promotion could never have landed however good its boundary evidence was.

Measured tree-wide, 541 _ZTV symbols in config: 276 agree, 259 disagree, 6
unreadable. The mechanism holds across history too -- of the 23 manifests with
status: promoted, 15 own a vtable and all 15 agree; none disagree.

Intersected with the 41-TU inline-destructor census: 11 agree, 48 disagree, 19
own no vtable. The destructor flip still lands independently, but the promotion
half of that list is gated on renames rather than on destructor mechanics.

The tool reads the chain directly -- _ZTV-4 -> typeinfo -> name ptr -> the
length-prefixed _ZTS string, which verifies itself. Three traps are documented
in the docstring because each produced wrong numbers before being caught:
read config from origin/main not a stale checkout, resolve each address in its
own overlay (ov045/046/047 share a base address), and use the decompressed
arm9_dec.bin. An unreadable typeinfo word is reported as unmeasured, never
folded into the agreeing column.


Claude-Session: https://claude.ai/code/session_01WdCK1xgrJdiJzPCh3bAJfQ

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
andrewboudreau added a commit that referenced this pull request Aug 31, 2026
#2075)

Two assertions in tools/test_tubuild.py went stale at #2066 (467bde0),
which moved ~PoleLift() into the class body. One of them was not merely
stale -- it asserted the anomaly #2066 fixed:

    assert "1 ordinal pair(s) NOT in ROM order: [(0, 1)]" in out

An out-of-line destructor makes mwccarm emit D2/D0/D1 with D0 before D1,
opposite the ROM; the pilot report called that non-fixable. Defining the
destructor inline emits exactly the ROM's two variants in ROM order. So
the only way to make this assertion pass by "fixing the code" would be to
revert #2066.

Measured across 467bde0^ -> 467bde0, swapping the shadow source and
the class header together (the pre-#2066 source does not compile against
the post-#2066 header, which is why this surfaced as a failing assert
rather than a quietly wrong number):

  before  37 sections, 8 .text, D2 present, "1 ordinal pair(s) NOT in
          ROM order: [(0, 1)]", 12 unlicensed
  after   35 sections, 7 .text, no D2, "ALL MATCH, ROM order", 11
          unlicensed

Section arithmetic: each vague-linkage symbol that stops being emitted
costs two sections, itself plus its own .rela. That is 43->37 at #1555
(3 symbols) and 37->35 here (1).

Rather than bump the numbers, the stale expectations become regression
guards stated as absences, so reintroducing an out-of-line definition
fails loudly here:

  assert "NOT in ROM order" not in out
  assert "_ZN8PoleLiftD2Ev" not in out
  assert "UNLICENSED function symbols" not in out

The last is an absence because tubuild prints no function block at all
when the count is zero -- asserting "(0)" would never match.

Negative control: reverting both files to 467bde0^ fails the new
assertion at tools/test_tubuild.py:166. The guards have teeth.

No path naming PoleLift appears in the new prose. PoleLift is a coined
name the cartridge contradicts (ROM RTTI: 18daObjKm2_Ami_Bou_c) and the
rename is a prerequisite for promoting this very TU, so such a path is a
dead reference with a scheduled fuse -- see #2074.


Claude-Session: https://claude.ai/code/session_01WdCK1xgrJdiJzPCh3bAJfQ

Co-authored-by: Claude Opus 5 <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.

1 participant