You've loaded a shared library binary into IDA Pro. It has no symbols. The disassembly shows a block of `JMP ABS.L` instructions at a known negative offset from the structure header — but every target is labeled `sub_1234AB`, `sub_5678CD`. You're staring at the library's **JMP table** — the dispatch mechanism for every public function — and it's entirely opaque.
Reconstructing the JMP table is the critical first step in any library reverse engineering effort. Once done, every `JSR (-N,A6)` in every application that uses this library becomes readable. This article covers the complete methodology: from raw hex dump to a fully annotated JMP table with function names, argument registers, and LVO mappings.
After loading, `SysBase` is at `$4`. Use `Edit → Segments → Create Segment` pointed at `$4` with type `WORD` to follow the pointer to `ExecBase`. Then navigate to `LibList` at offset `+0x17A` and walk the linked list.
---
## Reading the JMP Table in IDA
1. Know the library base address (e.g., `DOSBase` from the `OpenLibrary` result)
2. Navigate to `lib_base - 6` — first user function slot
3. IDA shows `JMP sub_XXXXXX` — the target is the actual function implementation
4. Rename each `sub_` with the function name from the LVO table
### Automated Script: `apply_lvo_names.py`
```python
import idaapi, idc
LVO_DOS = {
-30: "Open", # LVO -30 = Open(name, mode) d1/d2
-36: "Close",
-42: "Read",
-48: "Write",
-54: "Input",
-60: "Output",
-126: "WaitForChar",
-138: "Delay",
# ... extend from dos_lib.fd
}
DOS_BASE = idc.get_name_ea_simple("_DOSBase")
dos_ptr = idc.get_wide_dword(DOS_BASE)
for lvo, name in LVO_DOS.items():
jmp_entry = dos_ptr + lvo
# read the JMP target: opcode at jmp_entry is 4EF9, target at +2
NDK39 `.fd` files define the exact register assignments and bias (LVO offset):
```
## NDK39/fd/dos_lib.fd (excerpt)
##base _DOSBase
##bias 30
##public
Open(name,accessMode)(d1,d2)
##bias 36
Close(file)(d1)
##bias 42
Read(file,buffer,length)(d1,d2,d3)
##bias 48
Write(file,buffer,length)(d1,d2,d3)
```
The `##bias` value **is** the positive LVO — the actual call offset is `−bias`.
---
## JSR −LVO(A6) Pattern in Disassembly
```asm
; Typical OS call site in disassembly:
MOVEA.L (_DOSBase).L, A6
JSR (-30,A6) ; Open(d1=name, d2=mode)
; D0 = file handle (BPTR) or 0 on error
```
In IDA, this appears as `jsr ($fffffffe2,a6)` with displacement `-30` (`$FFFFFFE2` in two's complement 16-bit). Applying LVO names makes this `jsr (Open,a6)`.
When the `.fd` file is unavailable — common for third-party libraries like `muimaster.library`, `reqtools.library`, or `miami.library` — you must reconstruct the table from the binary.
### Step 1: Locate the Table by Scanning for JMP Opcodes
A JMP table is a dense cluster of `4EF9` opcodes at 6-byte intervals:
```python
# IDA Python: find JMP table clusters
def find_jmp_tables(min_entries=10):
"""Scan for clusters of JMP ABS.L (4EF9) at 6-byte spacing."""
ea = idc.get_inf_attr(INF_MIN_EA)
max_ea = idc.get_inf_attr(INF_MAX_EA)
clusters = []
while ea <max_ea:
if idc.get_wide_word(ea) == 0x4EF9: # JMP ABS.L
# Check if next 6-byte offset is also 4EF9
count = 1
test_ea = ea - 6
while test_ea > idc.get_inf_attr(INF_MIN_EA):
if idc.get_wide_word(test_ea) == 0x4EF9:
count += 1
test_ea -= 6
else:
break
if count >= min_entries:
clusters.append((test_ea + 6, count))
ea += 2
return clusters
for start_ea, count in find_jmp_tables():
print(f"JMP table at {start_ea:#010x}: {count} entries")
```
### Step 2: Find the Library Base
The first JMP table entry (the Open() standard at LVO -6) sits 6 bytes before the library base. The library base itself starts with `struct Library` — identifiable by the `lib_Node.ln_Type` field (NT_LIBRARY = 9) at offset `+8`.
```c
/* Verify we found the right structure: */
BYTE type = *(BYTE *)(library_base + 8);
if (type == 9) { /* NT_LIBRARY — confirmed */ }
```
### Step 3: Extract Function Names from Debug Strings
Many libraries contain inline debug strings naming each function. Search for printable ASCII near the JMP targets:
"""Look for function name strings near JMP targets."""
for lvo in range(-6, -300, -6):
jmp_ea = lib_base + lvo
if idc.get_wide_word(jmp_ea) == 0x4EF9:
target = idc.get_wide_dword(jmp_ea + 2)
# Search 64 bytes around target for a null-terminated string
for offset in range(-32, 32):
name = idc.get_strlit_contents(target + offset)
if name and name.isalpha():
print(f"LVO {lvo:+d}: candidate name '{name}'")
break
```
### Step 4: Verify by Argument Register Usage
Cross-reference the reconstructed LVO names with the NDK `.fd` register assignments. If `dos_lib.fd` says `Read(file,buffer,length)(d1,d2,d3)` and the function at LVO -42 uses D1, D2, D3 as arguments, the identification is confirmed.
---
## Decision Guide — Manual vs Automated Reconstruction
**What it looks like** — a JMP table entry pointing to an `RTS` instruction:
```asm
JMP sub_RTS_only ; LVO -156 = dos.library ???
; at sub_RTS_only:
RTS ; empty function — this is a stub
```
**Why it fails:** Some libraries include **private** or **reserved** LVOs that are intentionally empty stubs. Assuming every JMP entry maps to a real function produces wrong annotations. These stubs exist to reserve table slots for future expansion.
**Correct:** Check the JMP target for more than just `RTS`. If the target has no meaningful code (just `RTS` or `MOVEQ #0,D0; RTS`), mark it as `_reserved_lvo_N` rather than guessing a function name.
### 2. "The Wrong LVO Increment"
**What it looks like** — calculating LVO as `−4 × slot` instead of `−6 × slot`:
```python
# BROKEN: 4-byte entries are for AmigaOS 1.x only
lvo = -4 * slot # wrong for all 2.0+ libraries!
```
**Why it fails:** AmigaOS 1.x ROM libraries used 4-byte JMP entries (JMP rel16). All 2.0+ libraries use 6-byte entries (JMP abs32). Using the wrong multiplier offsets every LVO after slot 0.
**Correct:** Always use `LVO = −6 × (slot + 1)`. Verify by checking the opcode at the first slot: `4EF9` = 6-byte JMP, `60xx` = 4-byte BRA rel.
### 3. "The Unsorted LVO Map"
**What it looks like** — applying LVO names in arbitrary order and getting some right, some wrong:
```python
# BROKEN: the dict iteration order may not match the table order
for lvo, name in LVO_MAP.items(): # Python 3.6+ preserves insertion order, but 3.5 doesn't
apply_name(base + lvo, name)
```
**Why it fails:** LVO maps are inherently ordered — slot 0 maps to `-6`, slot 1 to `-12`, etc. If the map is applied out of order and a duplicate LVO exists, the wrong name gets applied last and overwrites the correct one.
**Correct:** Iterate in sorted LVO order and verify each entry against the expected JMP opcode before renaming.
---
## Use-Case Cookbook
### Dump an Unknown Library's Full LVO Table
```python
# IDA Python: extract and dump the JMP table of any library
| `.fd` file maps LVO→name | `.idl` / `.h` COM interface definition | ELF symbol table `.dynsym` | `.fd` is human-readable text; COM/ELF use binary metadata |
| Library base from `OpenLibrary()` | `CoCreateInstance()` returns interface ptr | `dlopen()` returns handle | Same pattern: opaque handle resolves to function table |
---
## FAQ
### How do I know when the JMP table ends?
The table ends when the pattern `4EF9` at 6-byte spacing breaks. The last valid entry is followed by the `struct Library` header at offset 0. The total number of entries is `lib_NegSize / 6` (stored in the library structure itself at a library-specific offset).
### What if the library uses 4-byte JMP entries (AmigaOS 1.x)?
1.x libraries (e.g., Kickstart 1.2/1.3 ROM) use `JMP rel16` (4 bytes: opcode `60xx` + 2-byte offset). To handle both: check the opcode at the first entry. `4EF9` = 6-byte, `60xx` = 4-byte. Adjust your LVO formula accordingly: `LVO = −4 × (slot + 1)` for 4-byte entries.
### Can SetFunction() break my JMP table reconstruction?
Yes. `SetFunction()` modifies the JMP table in RAM — the `4EF9` target address changes. If you're analyzing a RAM dump rather than a disk binary, some entries may point to patches rather than original functions. Always note whether your analysis target is a cold binary or a live memory snapshot.