Tools and notes for converting IBM J9/CDC JXE (rom.classes) images back to standard
Java .class / .jar, then decompiling them into readable Java - with inlined
static final constant names recovered at the bytecode level.
The JXE is a romized image: J9 strips StackMapTable, line numbers, local-variable names
and folds static final constants into raw literals. This project undoes what can be
undone (structure, generics, inner classes, throws, constant names) and is honest about
what the ROM erased for good (real local names, line numbers).
- Pipeline at a glance - end-to-end diagram + one-shot commands
- Repository Structure
- Decompilation Workflow - the 6 numbered steps
- Constant un-inlining (ASM) - tiers, the distinctiveness gate, sink-domain fixpoint, 3-way QA
- Decompiler reference - full Vineflower / CFR option sets (per-tool docs in
tools/README.md) - How conversion works - converter internals: J9 opcode space, what
jxe.pyreads, and every classfile fixup - Testing Workflow - Usage Notes - See Also - Credits
Two independent halves: converter (src/, JXE -> verifiable .jar) and recovery +
decompile (tools/, .jar -> readable Java). The un-inliner sits between conversion
and decompilation - it rewrites selected folded literals into getstatic Owner.FIELD so the
decompiler prints candidate names whose values are verified. End-to-end, one image:
tools/uninline/build.sh # once (JDK 17+)
python3 src/jxe2jar.py in.jxe out/base.jar # convert
tools/combine.sh out/base.jar out/combined.jar --appimg <APPIMG> # + bundles & app jars
python3 tools/donor_lvt.py apply out/combined.jar libs/donor/mh2p-lvt.json.gz -o out/combined-lvt.jar # names from MH2p
tools/uninline/uninline.sh pipeline out/combined-lvt.jar out/final.jar out/doubtful.tsv 100
bash tools/vineflower.sh out/final.jar out/final-vf # decompile
python3 tools/nav_index.py out/final.jar out/nav # navigation index| Path | Description |
|---|---|
src/ |
JXE -> JAR converter (Python): jxe.py (ROM parser), constpool.py (constant pool), bytecode.py (J9 -> JVM bytecode), jxe2jar.py (classfile assembly + CLI), common.py |
tools/uninline/ |
ASM constant/accessor recovery suite - self-contained fat jar (Uninliner - SinkResolve - RefineResolve - AccessInline - VerifyResolve - TypeAudit). See its README |
tools/ |
Merge (combine.sh), decompile drivers (vineflower.sh, cfr.sh) and post-processors (decompile_fallback.py, fix_vf_artifacts.py, int2hex.py, nav_index.py, xref.py) |
libs/ |
5 curated SDK jars (osgi.framework, osgi.util.tracker, commons.logging, xerces, commons-codec), fed to Vineflower as --add-external (type resolution) |
jvms/ |
Bundled runtimes: Zulu JDK 8 (mac/linux/win - the target 1.2/1.3 stdlib) + WM5 emulator for jar2jxe |
test/custom_edgecases/ |
Edge-case suite (Java 1.2, 54 classes) validating the converter via JAR -> JXE -> JAR round-trip; new cases are romized unattended with tools/wm5/ |
vms/ |
XP VM + WM5 emulator hosting the legacy jar2jxe.exe (round-trip direction) |
out/ |
Conversion outputs, decompiled trees, logs, nav indexes |
The converter only produces .class/.jar; turning that into readable Java is a separate
pipeline built around Vineflower (VF), with CFR as a fallback. The constant un-inliner runs
between conversion and decompilation - it is the step that makes the output read like source
instead of a wall of magic numbers.
-
Convert the JXE to a JAR:
python3 src/jxe2jar.py input.jxe out/base.jarConverts every ROM class in the image, including the JDK /
java.*ones. -
Combine the lsd base with the firmware's OSGi bundles and app jars into one jar:
tools/combine.sh out/base.jar out/combined.jar --appimg <APPIMG><APPIMG>is the app image root (.../advanced/<MODEL>-appimg); it auto-addseso/bundles,eso/bundles_prod,eso/hmi/lsd/jars(lsd wins name collisions). Folding them in before the un-inliner gives cross-corpus constant recovery and lets Vineflower resolve all app code intra-jar (no--add-externalfor it). Skip if you only want the bare lsd. The bundle jars were built by javac 1.1-1.4, which never wroteEnclosingMethod; without it Vineflower emits every anonymous class as a standaloneOuter$N.javawith a danglingthis$0(MU1316: 631 files, 507 of them not compiling, 45% of all compile failures).combine.shtherefore ends withtools/add_enclosing_method.py, which recovers the enclosing class and method from the singlenew Outer$Nsite exactly as the converter does for ROM classes (644 classes on MU1316, 3 s). -
Graft local-variable names from the MH2p donors, then recover inlined constant names with the ASM un-inliner - both before decompiling:
python3 tools/donor_lvt.py apply out/combined.jar libs/donor/mh2p-lvt.json.gz -o out/combined-lvt.jar tools/uninline/uninline.sh pipeline out/combined-lvt.jar out/final.jar out/doubtful.tsv 100The donor goes first because it matches methods by bytecode fingerprint and the un-inliner rewrites
ldcintogetstatic(37807 exact matches on the combined jar vs 36076 after un-inlining); ASM carries the graftedLocalVariableTables through the rewrite untouched (66032 methods with an LVT before and after; the verifier reports exactly the same classes with and without the graft). See Donor names.javacfoldsstatic finalconstants into raw literals, so a naive decompile showsgetChoiceModel(402127)instead of a symbolic name. This pipeline rewrites selected literal loads intogetstatic Owner.FIELD(value-preserving, decompile-only), so Vineflower then prints candidate names - no regex over source text. Every rewrite is provenvalue(F) == literal(0 mismatches on 106,037 replacements in the current MU1316 cycle with the two-pass sink pipeline). Full detail: Constant un-inlining (ASM). -
Decompile with Vineflower:
VF_JCL=libs/jcl/<FW>/jcl.jar tools/vineflower.sh out/final.jar out/final-vfvineflower.shpicks the newest bundledvineflower-*.jar, runs it on a JDK 17+ (VF 1.12 targets class 61) while pointing--include-runtimeat a bundled JDK 8rt.jar, and uses thejadvariable renamer (tinycrashes on these classes).VF_JCL(optional) resolves against the firmware's own JCL (seelibs/jcl/) instead of generic SE8. With theInnerClassesattribute present, nested classes render inline instead of as separateOuter$Inner.javafiles. See Decompiling with Vineflower. -
CFR fallback for the rare method Vineflower cannot structure:
python3 tools/decompile_fallback.py out/final-vf out/final.jarFinds any
// $VF: Couldn't be decompiledstub and re-decompiles just that class with CFR, whose control-flow analysis handles cases VF'sDomHelperrejects. -
Repair artifacts, index:
python3 tools/fix_vf_artifacts.py out/final-vf --apply # remaining VF rendering errors python3 tools/fix_javac_casts.py out/final-vf --cp out/final.jar --apply # JDK runtime, not the firmware JCL (see tools/README) python3 tools/int2hex.py out/final-vf --apply # hex bitmasks/flags python3 tools/nav_index.py out/final.jar out/nav # who-uses / what-is-N index -
Check compilation and try compiler-gated CFR fallbacks:
python3 tools/recompile_check.py out/final-vf --jar out/final.jar --jcl libs/jcl/<FW>/jcl.jar --json-report out/compile.json python3 tools/cfr_compile_fallback.py out/final-vf out/final.jar out/compile.json --jcl libs/jcl/<FW>/jcl.jar --apply python3 tools/recompile_check.py out/final-vf --jar out/final.jar --jcl libs/jcl/<FW>/jcl.jar --json-report out/compile.json python3 tools/foreach_compile_fallback.py out/final-vf out/final.jar out/compile.json --jcl libs/jcl/<FW>/jcl.jar --applyFor the definitive per-source count across mixed Java 1.4/1.5 classes, run
tools/isolated_compile_check.pywith--version-aware --filter-clashing-classes. It reports each source independently against the original class JAR; see the tool guide. CFR gates each replacement in isolation. The for-each tool also checks the complete source tree before promoting changes. Re-run the compile check afterward; neither decompiler guarantees a complete build.
The output is readable Java with inline nested classes, throws clauses, generics, symbolic
constant references, and hex bitmasks.
Optional but recommended for real head-unit firmware (MIB
lsd.jxe):
- step 2
combine- folds the image's bundles + app jars in, so cross-corpus constant recovery works and Vineflower resolves app code intra-jar. Skip only for a bare, standalone jxe.- step 4
VF_JCL- resolves against the firmware's own CDC JCL (libs/jcl/) instead of a generic SE8 rt.jar, so@Override/generics/overloads bind to the real API.Both can be omitted for a quick decompile, but firmware context makes the result more complete and accurate. For a one-off jxe, run steps 1, 3-6 before the optional compilation check.
javac folds static final constants into raw literals, so decompiled code shows
getChoiceModel(402127) instead of the symbolic name. The tools/uninline/
suite (self-contained ASM fat jar) can rewrite a literal-load into a candidate
getstatic Owner.FIELD, so a re-decompile renders a symbolic name - no regex on source
text. Every rewrite is value-preserving (VerifyResolve proves value(F)==literal)
and decompile-only, but equal values do not prove that the name was in the original source.
The default pipeline first learns call-argument domains from field references already
present in the input, then recovers candidate names across the corpus. It keeps bitmap
arrays numeric and requires a label-related name at setLabelId(int) call sites.
Use scoped-pipeline when an audit calls for fewer inferred names; the older,
more aggressive pass order remains available as heuristic-pipeline.
flowchart LR
L["literal load<br/>ldc 402127"] --> T1["T1 closure-unique"]
L --> T2["T2 global + distinctive"]
L --> T3a["T3a co-reference"]
L --> T3b["T3b sink-domain<br/>dataflow"]
T1 & T2 & T3a & T3b --> R["RefineResolve<br/>3-way QA"]
R -->|keep| G["getstatic Owner.FIELD"]
R -->|revert| N["honest number"]
G --> A["AccessInline<br/>inline access$NNN"]
| tier / pass | what it resolves |
|---|---|
| T1 closure-unique | value is unique within the use-site class's inheritance closure |
| T2 global + distinctive | globally-unique value passing strict distinctiveness (FQN/static-import context) |
| T3a co-reference | referenced-owner unique + distinctive |
| T3b sink-domain | learns each (callee, method, argIndex) slot's constant family from surviving getstatic args (self-seeded by T1/T2/T3a), then resolves non-distinctive literals flowing into a learned slot (fixpoint + field-write and switch-key-set domains) |
| RefineResolve | 3-way QA: keep by family-cohesion / distinctiveness / lexical overlap; revert genuine collisions to an honest number; the rest become inline /* ?? maybe Owner.FIELD */ doubts |
| AccessInline | inlines synthetic access$NNN accessors to the real field/method access (reads the body, stack-guarded) |
| VerifyResolve | instruction-diff proving every replacement is literal V -> getstatic F where value(F)==V |
| TypeAudit | field ConstantValue vs descriptor, ldc2_w long/double typing, jsr/ret, modified-UTF-8 edges |
Every literal load is a candidate (value, category) where category in {i (int-family I/S/B/C/Z), j (long), f (float), d (double), s (String)}. Matching is category-exact - a
getstatic must have identical stack behaviour - and T2/T3 only target public fields in public
classes (a getstatic from anywhere must be legal). The tiers are tried strongest-first:
flowchart TD
V["literal (value, cat)"] --> C1{"unique in the class's<br/>inheritance closure?"}
C1 -->|yes| T1["T1 - getstatic (simple-name scope)"]
C1 -->|no| C2{"globally 1 public owner<br/>AND distinctive?"}
C2 -->|yes| T2["T2 - getstatic (cross-scope)"]
C2 -->|no| C3{"class already refs owner O<br/>value unique in O AND distinctive?"}
C3 -->|yes| T3a["T3a - getstatic (co-reference)"]
C3 -->|no| C4{"flows into a learned<br/>sink slot of owner O?"}
C4 -->|yes| T3b["T3b - getstatic (sink-domain)"]
C4 -->|no| KEEP["left as a number"]
The distinctiveness gate (distinctiveNum) is what stops a coincidence like 1024 (screen
width, shared by dozens of constants) from being named. A numeric value is non-distinctive -
and so rejected by T2/T3a - if any of these hold (a = |value|):
| rule | rejects | why |
|---|---|---|
a < 4096 |
small ints | loop bounds, indices, bytecode-ish values |
a & (a-1) == 0 |
powers of two | 1024, 65536 - sizes, not IDs |
(a+1) & a == 0 |
2^n - 1 |
0xFFFF all-ones masks |
bitCount(a) <= 2 |
sparse flags | 0x10001 - bit combos |
a % 10 == 0 |
decimal-round | 1000, 50000 - quantities, timeouts |
a & 0xFF == 0 |
byte-aligned | addresses/offsets |
shifted 2^n-1 mask |
0x3FF << k |
shifted masks |
Strings need length >= 8 and at least one letter. Everything that survives the gate is a "distinctive" value - the kind that is almost certainly a real symbolic ID, not arithmetic.
The distinctive gate deliberately gives up on round numbers - but many real constants (log
levels 1000/10000, timeouts) are round. T3b recovers them by where the literal flows,
not its shape, using shallow intra-procedural dataflow (ASM Analyzer<SourceValue>). It learns
a slot's constant family from the getstatics T1/T2/T3a already placed:
| sink slot | keyed by | learned from |
|---|---|---|
| call-arg | (callee owner, method, desc, argIndex) |
a getstatic passed as that argument |
| field write | (owner, name, desc) |
a getstatic stored to that field |
| switch-on-param | (method, param) |
switch keys that match an owner's whole constant set |
A slot is trusted only when >= 2 distinct constants from a single owner back it; then a non-distinctive literal flowing into that slot is rewritten to that owner's uniquely-matching constant. Because each pass adds getstatics that seed more slots, it runs to a fixpoint (<= 6 iterations, monotonic - only facts added). Return-value domains are intentionally not learned (utility methods returning status/ID/bool-as-int would poison the family).
Every getstatic-to-int is re-judged lexically (tokenize camelCase / UPPER_SNAKE, alias
swdl/uota/gps/...):
| verdict | when | result |
|---|---|---|
| KEEP | distinctive value or own-class constant or family-cohesion (>= 2 constants of that owner used in the class) or the field-name tokens overlap the using method/callee name | symbolic reference stays |
| REVERT | non-distinctive and foreign and a lone use of that owner and the enclosing method name carries a generic quantity word (count/size/timeout/...) with a natural value |
back to an honest number |
| FLAG | any other non-distinctive + foreign case | number + inline /* ?? maybe Owner.FIELD */ |
tools/uninline/build.sh # build uninline.jar (JDK 17+)
python3 src/jxe2jar.py in.jxe out/base.jar # convert (all classes)
tools/combine.sh out/base.jar out/combined.jar --appimg <APPIMG> # + bundles & app jars
python3 tools/donor_lvt.py apply out/combined.jar libs/donor/mh2p-lvt.json.gz -o out/combined-lvt.jar # names from MH2p
tools/uninline/uninline.sh pipeline out/combined-lvt.jar out/final.jar out/doubtful.tsv 100
bash tools/vineflower.sh out/final.jar out/final-vf # decompile
python3 tools/nav_index.py out/final.jar out/nav # navigation index
# QA (optional): tools/uninline/uninline.sh audit out/final.jarOn MU1316 with the default balanced pipeline: 105200 literal->getstatic replacements,
0 value-mismatches. The scoped pipeline recovered 38205; the earlier heuristic
pipeline recovered 108609. These checks
verify values, not semantic name correctness. See
tools/uninline/README.md for per-tool commands and guarantees.
The individual tools - decompile drivers (vineflower.sh, cfr.sh), repair passes
(decompile_fallback.py, fix_vf_artifacts.py, int2hex.py) and analysis (nav_index.py, xref.py) - are each documented in
tools/README.md with flags and examples. This section keeps the two
things too bulky to inline there: the full Vineflower and CFR option sets.
Not recoverable (romizer-stripped, confirmed by an optional-flags census): real local variable
names (Vineflower's jad type-based names are the ceiling), runtime annotations, line numbers,
SourceFile. Generics are not stripped - see How conversion works.
Log-level names (Level.CRIT/ERR/WARN/INFO/DBG) are recovered only where a Level constant is
in the class's closure; the round verbosity values (1000/10000/...) are non-distinctive, so
cross-scope log(100000, ...) calls stay numeric rather than risk a misleading name.
Recommendation: Vineflower for primary analysis - fewer artifacts, cleaner inner-class handling, no broken anonymous imports. Keep CFR output alongside for cross-referencing on the rare method VF still can't do.
Use VF 1.12.0, renamer jad. Two settings decide how many methods come out as
// $VF: Couldn't be decompiled stubs on J9-converted classes:
--variable-renaming=jad(nottiny):tiny/TinyNameProviderNPEs during LVT renaming.- VF 1.12.0 (not 1.11.2): 1.11.2's
ExitHelper.cleanUpUnreachableBlocksNPEs on methods with unreachable blocks; fixed upstream in 1.12.0.
Use the wrapper, which sets all options, adds JDK8 rt.jar, and auto-includes libs/*.jar:
bash tools/vineflower.sh out/MU1316-lsd.jar # output: out/MU1316-lsd-vf/
bash tools/vineflower.sh out/MU1316-lsd.jar out/custom-dir # custom output dirTwo Javas are involved:
- VF 1.12.0 runs on Java 17+ (its own classes are class-file 61). The wrapper finds one via
/usr/libexec/java_home -v 17+, or honoursVINEFLOWER_JAVA=/path/to/java17+/bin/java. --include-runtimestays JDK 8 underjvms/zulu8.../(itsrt.jaris what the target 1.2/1.3 firmware classes resolve against). That JDK8 must still be present.
Equivalent manual command
# note: run with a Java 17+ (VF 1.12.0 needs it); --include-runtime still points at JDK 8.
java17+ -Xmx30g -jar tools/vineflower-1.12.0.jar \
--decompile-generics=true \
--decompile-enums=true \
--decompile-assert=true \
--decompile-finally=true \
--decompile-inner=true \
--decompile-java4=true \
--decompile-switch-expressions=true \
--remove-bridge=true \
--remove-synthetic=true \
--remove-empty-try-catch=true \
--remove-getclass=true \
--hide-default-constructor=true \
--hide-empty-super=true \
--override-annotation=true \
--inline-simple-lambdas=true \
--use-lvt-names=true \
--use-method-parameters=true \
--boolean-as-int=true \
--simplify-stack=true \
--incorporate-returns=true \
--pattern-matching=true \
--ternary-in-if=true \
--ensure-synchronized-monitors=true \
--ignore-invalid-bytecode=true \
--decompiler-comments=true \
--dump-bytecode-on-error=true \
--variable-renaming=jad \
--rename-parameters=true \
"--include-runtime=path/to/jdk8" \
"--banner=" \
"--indent-string= " \
--preferred-line-length=120 \
--thread-count=14 \
--old-try-dedup \
--verify-merges \
--warn-inconsistent-inner-attributes=false \
--add-external=path/to/jdk8/jre/lib/rt.jar \
--add-external=libs/commons-codec-1.16.0.jar \
--add-external=libs/org.apache.commons.logging-4.3.1.jar \
--add-external=libs/org.apache.xerces-2.9.0.jar \
--add-external=libs/org.osgi.framework-1.10.0.jar \
--add-external=libs/org.osgi.util.tracker-1.5.4.jar \
out/MU1316-lsd.jar out/MU1316-lsd-vfKey options:
--variable-renaming=jad --rename-parameters=true- readable names derived from type (J9 ROM has no debug info / LocalVariableTable). Usejad, nottiny.--decompile-inner --remove-synthetic --remove-bridge- inline anonymous classes, hide compiler-generated methods.--ignore-invalid-bytecode- don't crash on J9-converted bytecode edge cases.--include-runtime=path/to/jdk8- gives VF JDK8's stdlib for@OverrideonRunnable,Iterator,Comparable, etc.--add-external=path- use--add-external=(not-e) when passing via scripts. Adds thelibs/SDK jars as type info without including them in output. Skip-javadoc.jar/-sources.jar.
tools/cfr.sh out/MU1316-lsd.jar out/MU1316-lsd-cfrcfr.sh picks the newest bundled cfr-*.jar, applies the option set below, and hands CFR the
JDK 8 rt.jar plus any libs/*.jar as --extraclasspath.
Equivalent manual command
java -jar tools/cfr-0.152.jar out/MU1316-lsd.jar \
--outputdir out/MU1316-lsd-cfr \
--silent true \
--comments true \
--showversion false \
--removeboilerplate true \
--removeinnerclasssynthetics true \
--decodelambdas true \
--decodefinally true \
--sugarasserts true \
--sugarenums true \
--sugarboxing true \
--decodeenumswitch true \
--decodestringswitch true \
--arrayiter true \
--collectioniter true \
--tryresources true \
--hidebridgemethods true \
--hidelangimports true \
--innerclasses true \
--removebadgenerics true \
--removedeadmethods true \
--relinkconst true \
--relinkconststring true \
--liftconstructorinit true \
--override true \
--renameillegalidents true \
--recover true \
--allowcorrecting true \
--tidymonitors true \
--labelledblocks true \
--usenametable true \
--eclipse true \
--extraclasspath path/to/jdk8/jre/lib/rt.jarKey options: --sugar*/--decode* recover high-level constructs; --removeboilerplate --removedeadmethods --removebadgenerics clean compiler artifacts; --hidebridgemethods --removeinnerclasssynthetics hide synthetic access methods; --recover --allowcorrecting
best-effort recovery on broken bytecode.
The repair passes that run after decompilation (fix_vf_artifacts.py, int2hex.py,
decompile_fallback.py) are documented in
tools/README.md.
The converter parses a JXE image into per-class ROM structures, then reassembles each into a
standard .class. Most of that is a mechanical 1:1 mapping across four modules. Two things sit
outside the per-class loop: an image-wide pre-pass that reads nesting from the ROM
(build_inner_meta) and finds each anonymous class's new site (build_anon_enclosing), and the
JXE's non-rom.classes zip entries, which are payload and are copied to the jar untouched:
flowchart TD
Z["JXE zip"] -->|"rom.classes"| A["ROM image bytes"]
Z -.->|"every other entry<br/>(properties, JSON, .so) copied as-is"| F
A -->|"jxe.py<br/>parse ROM structs"| B["per-class J9ROMClass<br/>(fields, methods, optinfo)"]
B -->|"image-wide pre-pass<br/>build_inner_meta / build_anon_enclosing"| M["nesting map +<br/>anon class new-sites"]
B -->|"constpool.py<br/>rebuild constant pool"| C["standard constant pool"]
B -->|"bytecode.py<br/>translate J9 opcodes"| D["JVM bytecode"]
M --> E["jxe2jar.py<br/>assemble .class + attributes"]
C --> E
D --> E
E --> F[".jar"]
The sections below document the non-obvious fixups - the specific points where the J9 ROM format diverges from a standard classfile, so a naive copy would produce something the JVM verifier or a decompiler rejects. This table is the map; each row links to the detail.
| Fixup | Where | Why a naive copy breaks |
|---|---|---|
| Classfile version <= 49 | jxe2jar.py |
ROM strips StackMapTable; v >= 50 verifier requires it |
invokespecial -> invokevirtual |
bytecode.py |
J9 devirtualizes final calls; verifier rejects the receiver |
| Constant-pool slot typing | jxe.py, constpool.py |
a ref read as a Double (or vice versa) is an Illegal type in constant pool |
| Modified UTF-8 | constpool.py |
NUL / astral chars are rejected by the loader |
| Empty ROM strings | common.py |
a zero-length J9UTF8 read as padding returns the next pool entry |
| Float constant recovery | jxe.py |
int and float share a slot shape; the kind stream says which |
| InnerClasses reconstruction | jxe2jar.py, jxe.py |
correct nesting + inline decompilation |
| EnclosingMethod | jxe2jar.py, jxe.py |
anonymous/local classes inline into their method (new site recovers what the ROM erased) |
Exceptions / throws |
jxe2jar.py |
recover declared checked exceptions |
Generics / Signature |
jxe.py, jxe2jar.py |
recover generic types at all three levels |
J9 keeps the standard JVM opcode numbering for the whole 0x00-0xAB range - aconst_null
is 0x01, bipush 0x10, ldc 0x12, getfield 0xB4, etc. - so most of a method body
copies through unchanged. Divergence is concentrated in two bands: the return family (0xAC+,
where J9 encodes the return width and sync/native variants in the opcode) and the J9-private
high opcodes (0xCA-0xFF). Those are what bytecode.py rewrites:
| J9 opcode(s) | value | -> standard | notes |
|---|---|---|---|
JBreturn0 JBsyncReturn0 JBreturnFromConstructor JBretFromNative0 |
AC AF E4 F4 |
return |
void return (sync/ctor/native collapse to one) |
JBreturn1 JBsyncReturn1 JBretFromNative1 |
AD B0 F5 |
ireturn/freturn/areturn |
width from method descriptor |
JBreturn2 JBsyncReturn2 |
AE B1 |
lreturn/dreturn |
2-slot return |
JBretFromNativeF/D/J |
F6 F7 F8 |
freturn/dreturn/lreturn |
typed native returns |
JBgenericReturn JBreturnToMicroJIT |
E5 F3 |
inferred | return kind derived from descriptor |
JBaload0getfield |
D7 |
aload_0 + getfield |
J9 fuses the this.field prefix; expanded to two ops |
JBinvokeinterface2 (+nop) |
E7 |
invokeinterface |
J9's 2-byte form -> JVMS 4-byte (count from descriptor); the trailing nop absorbs the realignment |
| J9 wide load/store | CB-D4 |
wide (C4) + op |
op lo hi 00 (4 bytes) -> wide op hi lo (4 bytes): same length, offsets round-trip exactly |
J9 wide iinc |
D5 |
wide iinc |
D5 lo hi clo chi 00 (6 bytes) -> wide iinc hi lo chi clo (6 bytes) |
JBldc2lw / ldc2dw |
14 / F9 |
ldc2_w |
distinct opcodes -> long vs double is deterministic (no ambiguity) |
JBbreakpoint JBasyncCheck JBimpdep1 JBimpdep2 |
CA FA FE FF |
nop |
JIT/debug hooks, no runtime effect |
invokespecial on Object finals |
B7 |
invokevirtual |
see devirtualization fixup |
One consequence ripples through the rest of translation: every rewrite that changes byte
length (aload0getfield, invokeinterface2, ldc->ldc_w when the CP index > 255) shifts
later offsets, so branch/switch targets and the exception table are recomputed against an
output-offset map. (Wide forms do not shift: J9's 4/6-byte encodings map onto wide forms
of the same length.)
Before any of that, jxe.py walks the J9ROMClass structures (not a classfile - a romized
image with SRP/self-relative pointers). Each row below is a field the reader must locate exactly,
because a wrong offset shifts everything after it:
| ROM source | -> classfile | gate / location |
|---|---|---|
ramConstantPoolCount, cpShapeDescription, kind-run stream (word 19) + CP slots |
constant pool | slot type = shape nibble (RAM part) or long/double (ROM tail); int/float and long/double from the kind stream, see slot typing |
J9ROMMethod {modifiers, bytecode, exceptions} |
methods + Code |
modifier bit 0x02000000 = generic Signature (u32 after bytecode); 0x00400000 = one reserved trailer word (AOT builds); byte 16 is never written by the romizer (not a size-high byte) |
J9ROMField {access, const slots} |
fields + ConstantValue |
access bit 0x40000000 = generic Signature |
outerClassName, memberAccessFlags, innerClasses |
InnerClasses |
header layout: outerClassName one word later than the old map assumed |
| optional-info SRPs (popcount-indexed) | Signature / EnclosingMethod |
class flag 0x02 = class Signature, 0x40 = EnclosingObject, 0x80 = simpleName |
throwExceptions |
Exceptions |
- |
The J9 ROM keeps no classfile version (the header words rubiks' tool labels major/minor
are the optional-info flags), so the converter has to pick one. It writes 46 (javac 1.2)
unless the class carries a feature that cannot exist below Java 5 - enum/annotation flags,
bridge/varargs methods, a generic Signature, or an ldc of a CONSTANT_Class (javac >= 1.5
compiles Foo.class that way; the verifier rejects it below 49 with "Illegal type in constant
pool", found by the junit round trip), or the class is a package-info (an illegal class name
below 49, found by the guava round trip) - in which case 49. On MU1316 that is 30158 classes
at 46 and 385 at 49.
ACC_SYNTHETIC does not bump the version: javac <= 1.4 marked synthetic members (this$0,
access$000, class$) with the Synthetic attribute, which the romizer folds into the
0x1000 flag - the firmware's own non-romized bundle jars show ~2000 such 45-48 classes. An
earlier heuristic stamped every synthetic-bearing class 49, which is wrong for a 1.2 class
(RemoteHMIService) and made the output unusable as a base for classes that go back onto the
device. --major N forces one version on every class when you need that.
Never >= 50. The J9 romizer strips StackMapTable, which the type-checking verifier (class
version >= 50) requires - so any >= 50 stamp yields a class that fails -Xverify:all with
"Expecting a stack map frame". Version <= 49 keeps every class on the old inference verifier,
which needs no stack maps.
- Problem: J9's romizer devirtualizes
invokevirtualof a final method intoinvokespecial(a final method binds statically). That is not the original bytecode, and standard bytecode forbidsinvokespecialto a superclass method on a receiver not assignable to the current class, so HotSpot's verifier rejects e.g.x.getClass()/otherThread.getName()with "Incompatible object argument for invokespecial". - Fix: the romizer's own rule, read out of
j9vmall23.dll(bcutilsub_1001299C, run on everyinvokevirtualbefore romization): rewrite iff the target is one ofjava/lang/Object's six final methods (wait()V,wait(J)V,wait(JI)V,notify()V,notifyAll()V,getClass()Ljava/lang/Class;- whatever class the reference names, javac uses the static receiver type), or the reference names the class being romized and that class itself declares the methodfinalorprivate. Nothing else: not inherited finals, not final classes, notsuper.m()._was_invokevirtualinverts exactly that (private calls wereinvokespecialalready, so only own finals and Object finals go back). A receiver-based heuristic was tried first; on MU1316 it agreed on 875 sites and wrongly "restored" 2 inner-classaccess$N01super accessors (this$0.super.m()), which the exact rule keeps. - Result: on MU1316, 2284 calls in 1366 methods go back to
invokevirtual, whole-jar-Xverify:allstays at 0VerifyError, and the edge-case suite matches the original javac bytecode opcode for opcode (test/test_roundtrip_verify.py).
A ROM constant-pool slot is two u32 words and the words alone do not say what it is: a
field/method ref is {classRefCPIndex, SRP nameAndSignature} and a long/double is {low, high}
- any long whose high word happens to index a class slot looks like a ref, and a ref whose class
index is large looks like a long. The old reader guessed, with a bound on the class index taken
from the image's class count (a no-op on a 30k-class firmware, but on the 47-class edge-case
image it turned every ref with class index >= 47 into a
Double:ManyConstants->VerifyError: Illegal type in constant pool).
The ROM records the type; the reader now uses it (census: 3047 classes / ~150k slots, zero contradictions with the opcodes that reference each slot):
index >= ramConstantPoolCount- the ROM-only tail - holds every long/double and nothing else.ldc2_lw/ldc2_dwat the use site decide long vs double.cpShapeDescription(4 bits per RAM slot, LSB-first per little-endianu32):1string,2class,3static field ref,0either a single-slot scalar (int/float, second word 0) or a method / instance field ref (second word = SRP, never 0).- Header word 19 = SRP to the "kind-run" stream (found by reversing the romizer's
bcutil): runs of{kindByte, count:u8, index[count]}ended by0xFF, odd kindByte = kind+1 withu8indices, even =u16indices; kinds14int,16float,18long,6double,20/22instance/static field,2/8/10/12static/virtual/special/interface method. So int vs float and long vs double are exact, no stack simulation (the old simulation missed one float on MU1316 and is gone); the distinctldc2_lw/ldc2_dwopcodes are only a cross-check. Present in every class of every image seen (cases.jxe, MU1316: 253828/253828 kinds agree with the referencing opcodes). - A ref's class index must land on a
2slot; the one remaining fallback (an unreadable ref) keeps the slot as an inertINTEGER 0and prints a warning - dropping a slot would shift every later CP index. The_sanitized_refplaceholder for a garbage name/descriptor stays. - Net result: whole-jar
-Xverify:allon MU1316 -> 0 VerifyError, 0 ClassFormatError (remaining non-OK are benign:java.*classes the app loader refuses to define, and classes whose supertypes live only in bundle jars); edge-case suite 54/54.
Classfile CONSTANT_Utf8 uses JVM Modified UTF-8 (JVMS 4.4.7), not standard UTF-8. The
converter previously wrote str.encode("utf-8", "surrogatepass"), which differs in two
loader-enforced ways: U+0000 must be 0xC0 0x80 (never a bare 0x00), and a supplementary
char must be its UTF-16 surrogate pair as two 3-byte sequences (never a single 4-byte sequence).
Plain ASCII/BMP text is identical, so this only bites strings carrying NULs or astral chars -
e.g. java.text.CollationRules, whose rule string embeds U+0000..U+001F, was rejected with
"Illegal UTF8 string in constant pool". _encode_utf8 now emits proper Modified UTF-8.
(Surfaced by the JDK/java.* classes, which the converter now always includes.)
A ROM string is a J9UTF8: LE u16 length, then that many bytes of Modified UTF-8, padded
to a 2-byte boundary. Records sit back-to-back in one shared pool, so an empty string is just
00 00 immediately followed by the next record. The reader used to try a "pad+length" layout
first (u16 padding, u16 length, data) and accept it whenever the padding word was zero -
which is true for exactly one input: an empty string. Every "" therefore consumed the
length and data of whichever record happened to follow it in the pool. On MU1326 that was
isNativeLittleEndian, which turned up as a bogus string constant in 1086 classes (java.io.File,
java.net.URI, java.lang.Class, ...). The pad+length path is gone; _read_j9utf8_at reads the
real layout and treats a zero length word as the empty string it is. Covered by
test/test_rom_string.py.
The same reader also capped a record at 10000 bytes and fell back to "" above that, which
emptied every string constant over 10 KB - the JCL's CJK converter tables
(com/ibm/oti/io/CharacterConverter_*, 27-41 KB each) came out as "" in 29 ldcs and 11
ConstantValues on MU1316. The cap is the u16 maximum now (BigConstants in the edge cases
carries a 33 KB string).
int and float share the J9 CP slot shape (both "scalar, second word 0"), so earlier
versions guessed the type with a linear stack simulation plus a ternary-shape matcher - and on
MU1316 still missed one float. The romizer never lost the information: header word 19 points
at a kind-run stream that lists every int, float, long and double slot (see
slot typing), so jxe.py types the
slot at parse time and bytecode.py only remaps the index. The simulation is gone.
Constant handling also covers J9ROMField constant slots emitted as ConstantValue for static
final fields (int/float/long/double/String; long/double are {low, high} in the field record,
String is a u16 ROM CP index).
The InnerClasses attribute is rebuilt from the actual J9 ROM fields, not guessed from $
names. J9 preserves per class (unlike StackMapTable): outerClassName, memberAccessFlags,
the innerClasses list and, as optional info, simpleName (flag 0x80) and an
EnclosingMethod marker (flag 0x40). build_inner_meta reads these (the header layout was
corrected - outerClassName sits one word later than the old field map assumed), so each entry's
outer/name/flags are exact - a nested interface keeps interface+abstract, a private member stays
private, etc.
Per class, the entry set mirrors javac: itself, its enclosing nest chain, its members, and every
nested class it references. References are collected from the constant pool, the
superclass/interfaces (which live outside the CP), all field/method descriptors, and - crucially -
the output constant pool's UTF-8 entries, since some refs (e.g. a synthetic <init>(Outer$1)
accessor call) are reconstructed during conversion and never appear as J9 ROM constants.
One flag J9 leaves at 0 is recovered: the ACC_STATIC on a synthetic/anonymous nested class. A
non-static inner class captures its enclosing instance in a synthetic this$0 field, so that
field's absence means the class is static - an exact, build-independent tell. (An earlier
ACC_FINAL heuristic only correlated on some builds.)
Where the original pre-romization jars are available, the reconstructed InnerClasses matches
them byte-for-byte, and it holds across different ROM builds/regions.
Anonymous and local classes also carry a J9 EnclosingObject record (optional flag 0x40):
{ classRefCPIndex, nameAndSignature }. It is read into the enclosing class and, when the ROM
kept it, the enclosing method's name+descriptor, then emitted as the JVMS 4.7.7 EnclosingMethod
attribute (class_index + optional method_index NameAndType).
This matters for decompilation: without it a decompiler treats every inner class as an
independent top-level class, so anonymous classes come out as separate Outer$N.java files,
synthetic access$NNN accessors get stripped from the outer class but are still called from the
inners (unresolved references), and private-inner constructors keep their synthetic disambiguator
argument (wrong arity). With EnclosingMethod present, Vineflower inlines anonymous classes into
their enclosing method and resolves all of these. The converter also preserves the ROM's
ACC_SYNTHETIC field bit (0x1000) for captured this$N and val$... fields. An older field
mask accidentally used 0x0100, so Vineflower treated captures as ordinary fields and emitted
invalid anonymous-class constructor arguments. The corrected flag lets Vineflower reconstruct
those captures directly; fix_vf_artifacts.py handles only independent residual source errors.
The new site fills in what the romizer erased. The ROM keeps the record for only a
fraction of anonymous classes - on MU1316, 403 of 10701 - so the rest used to fall back to the
name (Outer$N -> Outer, no method). But an anonymous class is instantiated exactly once,
at its declaration site, so the single new Outer$N in the image names both the class it was
declared in and the method. build_anon_enclosing (jxe2jar.py) scans every method body for
new (find_new_class_indices in bytecode.py), resolves the ROM constant to a class name and
keeps only unambiguous single-site hits. On MU1316 this takes method_index == 0 from
10298 down to 668 and corrects 41 class_index values where the name prefix was the wrong
outer (e.g. java/util/HashMap$1 is declared in HashMap$HashMapEntrySet, not HashMap).
A <init>/<clinit> site yields the class only, never the method: a field or instance
initializer is compiled into the constructor and javac emits method_index 0 for those, so
naming the constructor could lie. Covered by test/test_anon_enclosing.py.
J9 keeps each method's declared checked exceptions (throw_exceptions), which the converter used
to drop - so decompiled methods showed no throws. The standard Exceptions attribute is now
emitted from them, and matches the original jars byte-for-byte where those are available.
Native methods keep their throws too: their exception-info block (same {catchCount, throwCount, ...} layout as a bytecode method's, with zero catch entries) used to be skipped as
opaque bytes, which lost 135 throws clauses on MU1316 (com/ibm/oti/connection/comm/Connection
natives and the like).
Contrary to an earlier assumption, the J9 romizer keeps the generic Signature at all three
levels - it was the reader that dropped them. An empirical optional-info census (counts, not
prose) proved it: class-level Signature sits at class optional-flags bit 0x02 (SRP index =
popcount of flags below it); method-level at J9ROMMethod modifier bit 0x02000000 (the u32
after the bytecode, which the parser used to read and discard); field-level at J9ROMField
access flag 0x40000000 (what the old code mislabelled a dead const_value3). All are now read
and emitted as JVMS Signature attributes (version bumped to 49), recovering 1117 signatures
on MU1316 - verified with javap (GPair<F,S>, GList<T>, create(A,B)->GPair<A,B>).
What is genuinely not recoverable (proven stripped by the same census, 0 across the whole
image): LocalVariableTable/LineNumberTable (real local/param names + line numbers),
StackMapTable, runtime Annotations, SourceFile. So locals keep Vineflower's jad
type-based names - a hard ROM limit, not a converter gap.
- Bytecode: wide opcodes normalized to JVM
wide;invokeinterfacecount computed from the descriptor;ldcpromoted toldc_wwhen CP index > 255; J9 prefix opcodes expanded (e.g. implicitaload_0); branch/switch offsets and switch padding rewritten using output offset maps; invalid/missing CP refs handled defensively instead of crashing. - J9 return opcodes mapped to standard JVM returns by method signature:
JBreturn0/JBsyncReturn0/JBreturnFromConstructor/JBretFromNative0->return;JBreturn1/JBsyncReturn1/JBretFromNative1->ireturn/freturn/areturn;JBreturn2/JBsyncReturn2->lreturn/dreturn;JBretFromNativeF/D/J-> typed returns;JBgenericReturn/JBreturnToMicroJIT-> inferred from descriptor. J9 runtime/debug opcodes (JBasyncCheck,JBbreakpoint,JBimpdep1,JBimpdep2) ->nop. - Code attribute: always JVM-standard
u16/u16/u32layout (stack/locals/code_len); exception table offsets rewritten after bytecode expansion. The romizer pads each method's bytecode to au32boundary withnops (the ROM stores the size in words); they can only follow a terminal instruction, so the converter drops them - javac output never has them and they were 1.8nops per method of pure noise in every diff. - Field
ConstantValuestrings are the ROM pool slot the field's constant word indexes, taken verbatim. An old guard blanked the value whenever it equalled another field's name (KEY_SERVICE_MANAGER = "serviceManager"next to a fieldserviceManager), which silently emptied 6 constants on MU1316. java/lang/Objectgetssuper_class = 0as JVMS requires, not a CP entry with an empty name.- Flags:
--strip-syntheticclearsACC_SYNTHETICon classes/methods/fields for strict tooling (preserved by default).
The romizer strips the LocalVariableTable, and with it every local and parameter name and the
declared type of every local (see the round-trip corpus below: Vineflower then writes
ArrayList arraylist for List list). The JXE itself has no way back - but the MH2p head units
ship the same eso/hmi/lsd code non-romized and compiled with -g
(Firmwares/HU/MH2p_*/extracted/app/eso/hmi/lsd/lsd.jar, major 48, LVT in ~60% of classes), and
their code base overlaps MU1316's: 14080 class names, 84816 methods, 66151 of them with identical
bytecode.
libs/donor/mh2p-lvt.json.gz is a database distilled from the three MH2p jars (64898 classes,
525803 method records, one per distinct bytecode per method, 16 MB): for every method the sha1 of
its symbolic instruction list (constant-pool entries resolved, so pool layout and ldc/ldc_w
do not matter) and its LVT entries keyed by instruction index. Applying it to a converted jar:
python3 tools/donor_lvt.py apply out/MU1316-base.jar libs/donor/mh2p-lvt.json.gz -o out/MU1316-lvt.jar- a method whose bytecode fingerprint matches a donor record gets the whole LVT (names and declared types, pcs remapped through instruction indices) - 37216 methods on MU1316;
- a method that only matches by name + descriptor gets the parameter names (the LVT rows at pc 0 in argument slots depend on the signature alone) - 13104 more;
- every touched class gets its
SourceFileback (6233 classes).
Apply it to the combined jar, before the un-inliner: the un-inliner turns ldc into
getstatic, which changes the fingerprint of every method it touches, so applied afterwards the
exact matches drop from 37807 to 36076. ASM carries the LVTs through the rewrite (same 66032
methods with an LVT before and after), and -Xverify:all reports exactly the same classes with
and without the graft. Note that the un-inlined jar itself is decompile-only: inlining
access$NNN accessors leaves invokespecial calls into other classes' private methods, which
HotSpot rejects ("Illegal use of nonvirtual function call", 1303 classes on MU1316) - verify the
converter's base jar, not the un-inlined one.
End to end on MU1316 (converter, donor, renamer plugin, EnclosingMethod for the bundle jars'
anonymous classes, fix_vf_artifacts, fix_javac_casts, compile-gated chain) against the previous
pipeline: the tree shrinks from 21249 to 20618 sources (631 standalone Outer$N.java fold into
their outer classes), isolated compile at a uniform -source 1.5 goes from 19799/21249 (93.2%) to
20394/20618 (98.9%, failures 1450 -> 224), version-aware from 19719 to 20500 (failures 1530 -> 118),
new X<>() 126 -> 0, varN names 236 -> 145, generic JAD names (obj, object3) 23681 -> 15671 -
the rest are real names from the donor. Nesting the anonymous classes moves 2 (uniform) / 19
(version-aware) pre-existing failures from the standalone file into the outer one: for-each and
auto-unboxing that Vineflower emits for 1.4 bytecode, log(..., null) overload ambiguity.
foreach_compile_fallback.py (now reading isolated reports) turned 316 of the 336 for-each loops
that javac 1.4 rejects back into iterator loops, most of the late ones only after repairing the
unboxing that the for-each syntax error had hidden from javac. explicit_unboxing.py grew from the
one X cannot be converted to Y shape into the diagnostic-driven 1.4 repair described in
tools/README.md: wrapper
operands of !, the binary operators and ?:, the Boolean.valueOf(flag) arguments Vineflower
hid from log(...) calls (overload chosen from the original class's constant pool; numbers box as
new Integer(x), the CDC 1.1 JCL has no Integer.valueOf(int)), the throws clauses the
obfuscated de/eso classes lost (none of their 581 methods kept an Exceptions attribute), the
reference casts Vineflower drops next to a generic API ((IContext) Preconditions.checkNotNull(x)),
the type arguments it prints into 1.4 classes (erased; the anonymous classes' parameters go back to
Object plus a cast on entry) and the cast a 1.4 ternary of unrelated operands needs - 607 of 639
candidates in the v9 cycle, every repairable error of a file per round. Rebuilding
libs/jcl/MHI2Q_US_AUG22_P5087_MU1316/ with the fixed converter (tools/jcl_rebuild.py; the July
jars had lost the throws of native methods such as Object.clone()) let 57 more files compile:
36 whose catch (CloneNotSupportedException) a since-deleted repair tool had removed and 21
clone() overrides javac rejected against the old JCL; fix_vf_artifacts.py learned the
self-referencing MAX_VALUE = MAX_VALUE constants of java/lang/{Integer,Long,Float,Double,Math}.java
and int flag = false; the isolated check keeps the firmware JCL on the plain classpath of
major-49 files so the device's org.conscrypt classes resolve. The 118 left version-aware (v9
full cycle, 2026-11-10) are 39 missing dependencies, 20 for-each loops over types that are not
Iterable (Vineflower matched an iterator(args) call), 9 catch clauses whose obfuscated callee
lost its throws (the class file needs repairing, not the source), 8 generics leftovers and a long
tail; the 224 at 1.5 are 123 missing dependencies, java.* sources measured against the JDK
instead of the firmware JCL (java.nio.Buffer constructors, isSynthetic()) and the same tail.
Coverage by package on MU1316: de/audi 17% of methods, de/vw 56%, com/ibm, edu/emory,
java/util ~99%; de/esolutions and org/dsi 0% - the MH2p build of those has no -g:vars, so
there is nothing to donate. HotSpot validates LVT ranges on load, so -Xverify:all doubles as the
check that every graft is well-formed (MU1316: unchanged 30217 ok / 326 environmental).
tools/donor_lvt.py build <lsd.jar ...> -o DB rebuilds the database (1 minute); without
--no-lines it also carries LineNumberTables (28 MB).
Vineflower reads the names through --use-lvt-names=true, but its bundled JAD renamer
(--variable-renaming=jad --rename-parameters=true, which tools/vineflower.sh used for readable
var1-free output) renames every variable - the renamer API hands a provider the variable's
type, never its current name, and applies whatever comes back. tools/vf-plugin/ is a 100-line
Vineflower plugin (tools/vf-lvt-first-renamer.jar, build with tools/vf-plugin/build.sh and a
JDK 17) whose provider skips every slot the method's LocalVariableTable names and gives
JAD-style names only to the rest; vineflower.sh puts it on the classpath and drops the two
renaming options when the jar is present (VF_NO_LVT_PLUGIN=1 restores the old behaviour).
The converter is validated through edge-case tests and a JAR -> JXE -> JAR round-trip:
- Build edge-case JAR:
sh test/custom_edgecases/build.sh - Convert JAR -> JXE with the real
jar2jxe.exe, unattended:python3 tools/wm5/host.py jar2jxe test/custom_edgecases/out/cases.jar test/custom_edgecases/out/cases.jxe --startup cases.Hello(seetools/wm5/README.md; the result is checked in) python3 test/test_roundtrip_verify.pyconverts it back and lets the JVM judge: every class must pass JDK 8-Xverify:all(test/VerifyAll.javalinks every class), carry version 46, and match the original javac bytecode opcode for opcode (ldc/ldc_wmasked)
pytest test/ runs that plus the unit checks (test_rom_string J9UTF8 reader,
test_anon_enclosing anonymous-class new-site recovery, test_devirtualize the
invokespecial restoration rule and float-argument detection, ...); each also runs standalone.
jar2jxe.exe is a WinCE ARM binary (j9jar2jxe23.dll -> j9bcutil_createRomClass): it
only runs in the WM5 Device Emulator inside the XP VM; tools/wm5/ drives that VM headlessly.
The same round trip on real-world jars, compared class by class with tools/rtdiff.py (own
classfile parser: attribute census, flags, ConstantValue/Exceptions/InnerClasses/
EnclosingMethod content, and every method's bytecode with constant-pool symbols resolved and
branch targets as instruction indices, so ldc/ldc_w and layout differences do not count),
plus JDK 8 -Xverify:all on both sides: a class that fails only on the round-trip side is a
converter defect, one that fails on both just lacks a dependency. 16 jars, ~4.5k classes, five
compiler generations (javac 1.1 .. 1.6 and ecj):
| jar (major) | classes | methods with code | bytecode identical | verify failures only on round trip |
|---|---|---|---|---|
| qnx installer (45) | 1171 | 10151 | 10098 (53 jsr methods) |
0 |
| guava 10.0.1 (49) | 1164 | 8452 | 8452 | 0 |
| commons-collections 3.2.2 (47) | 460 | 4091 | 4091 | 0 |
| equinox osgi 3.3 (46, ecj) | 435 | 3483 | 3483 | 0 |
| junit 4.13.2 (49) | 350 | 1786 | 1786 | 0 |
| dom4j 1.1 (45) | 333 | 3309 | 3307 (2 jsr) |
0 |
| velocity 1.7 (48) | 270 | 2060 | 2050 (10 jsr) |
0 |
| antlr 2.7.2 (45) | 193 | 2102 | 2100 (2 jsr) |
0 |
| struts-core 1.3.8 (48) | 150 | 1525 | 1525 | 0 |
| commons-lang 2.6 (47) | 133 | 2343 | 2343 | 0 |
| dsi2gen (46/49) | 116 | 1205 | 1205 | 0 |
| commons-beanutils 1.7.0 (46) | 111 | 979 | 979 | 0 |
| commons-digester 1.8 (46) | 100 | 642 | 639 (3 jsr) |
0 |
| equinox common / jobs 3.3 (45, ecj), kxml2 2.3 (46) | 114 | 1037 | 1037 | 0 |
| edge cases (46) | 55 | 203 | 203 | 0 |
43298 of 43368 methods come back opcode-for-opcode identical; the other 70 are the ones javac
<= 1.4 compiled with jsr/ret subroutines (finally), which the romizer inlines before it
writes the ROM - equivalent code, no way back. Exceptions, static ConstantValue, Signature
(5252 of them) and EnclosingMethod (incl. the enclosing method's name) match the originals
exactly; 6910 throws clauses, 3741 constants, 0 differences.
What the romizer strips for good (identical across all 16 jars, nothing to recover):
SourceFile, LineNumberTable, LocalVariableTable/LocalVariableTypeTable, Deprecated,
all annotations, ConstantValue on final instance fields (static ones are kept), the
InnerClasses entries for foreign nested classes a class merely references
(java/util/Map$Entry), and the inner-class access flags (the converter derives them). The
Synthetic attribute of javac <= 1.4 comes back as the ACC_SYNTHETIC flag - a representation
change, not a loss. For decompilation the one that matters is the LocalVariableTable: with it
gone Vineflower declares ArrayList arraylist where the source said List list, widens short
locals to int, adds casts, and occasionally picks Object for a String.
Decompiler oracle (tools/decompile_oracle.py): the same corpus also measures the decompile
stage, because here the original source structure and a LocalVariableTable are known.
Both the original jar and its round trip are decompiled with tools/vineflower.sh, every file
is compiled alone against the original jar at -source 1.5, and the recompiled methods are
compared. Of 3500 round-trip files, raw Vineflower output compiles for 2704 (77%); the missing
LVT accounts for only 9 of the failures (readability, not compilability), ~290 are missing
third-party dependencies, and the rest were decompiler defects that hit firmware code just the
same: class$ literal leftovers (150), new X<>() diamonds in 1.4 code (138), raw
for (String s : list) loops (91) and casts dropped around doPrivileged/toArray/get
results (42). With fix_vf_artifacts.py (three new rules) and fix_javac_casts.py the
round-trip trees compile for 3043 files (87%, 95% of those whose dependencies exist), and
27786 of 28223 recompiled methods are identical to the recompiled LVT-side source.
This corpus is what found the six defects fixed in November 2026 (string constants over 10 KB,
throws on natives, trailing nop padding, a ConstantValue heuristic, super_class of
Object, and the two classfile-version rules ldc CONSTANT_Class / package-info). To rerun:
romize with host.py jar2jxe, convert, then python3 tools/rtdiff.py original.jar roundtrip.jar.
- Every class in the image is converted (including JDK /
java.*); there is no skip list. EnclosingMethodis synthesized for ROM-erased anon classes (Outer$N) by default so decompilers inline them; pass--dont-infer-enclosingto turn that off.- The JXE's non-
rom.classeszip entries (properties, JSON config,jxeLink.rules, native.so- 21 files on MU1316) are copied into the output jar; the ROM image is only its class half. ACC_SYNTHETICis preserved by default;--strip-syntheticfor strictjavapon 45.0 classes.- Classfile version is 46, or 49 for classes with a Java 5 feature (never >= 50: keeps every
class on the stack-map-free inference verifier).
--major 46forces one version everywhere, e.g. for classes you patch and load on the device. See Classfile version. - Some large binaries/ISOs are referenced via
.urlfiles pointing to original archives (vms/xp/*.iso.url).
tools/README.md- every decompile/recovery tool, with flags and examplestools/uninline/README.md- the ASM constant/accessor recovery suitesrc/README.md- format knowledge and implementation detailstest/custom_edgecases/README.md- test coverage listvms/xp/README.md- WM5 emulator + XP VM instructions
Thanks to the original repo, forks, and contributors: