Files
pikuma_ps1/scripts/elf32.lua
T
2026-08-06 10:41:45 -04:00

419 lines
20 KiB
Lua

-- elf32.lua — Pure-Lua ELF32 format helpers with no lfs / no lpeg dependency.
-- The reload helper's `parse_manifest` (scripts/pcsx_debug_helper/reload.lua)
-- and the metaprogram's `read_elf_sections` + `read_nm` (scripts/elf_dwarf.lua)
-- both parsed ELF32 headers from wire bytes.
--
-- This module contains the format constants and the byte-level walker.
--- The metaprogram side keeps `read_u32_le` / `read_u16_le` as local forwarders; the helper side calls `E.*` directly.
--
-- **Adapter contract (explicit pass style):**
-- The helper VM's `Support.File` exposes byte-read methods that require `self` (fileffi.lua:225-227),
-- so callers wrap once in a 1-line adapter that strips `self`.
-- The parsers here operate on the unwrapped form.
-- Reads are flat function calls — `E.read_u8(adapter, off)`, `E.read_u32(adapter, off)`, `E.size(adapter)`.
-- read_u8(adapter, off) -> integer | nil
-- read_u16(adapter, off) -> integer | nil
-- read_u32(adapter, off) -> integer | nil
-- size(adapter) -> integer
--
-- **Convention:** every offset in the constants tables is a zero-based wire offset.
-- The `+ 1` conversion happens only at the `string.byte` boundary inside the readers.
--
-- spec: System V ABI gABI v1.2 §"ELF Header" (Table 1) + §"Section Header Table"
-- spec: System V ABI gABI v1.2 §"Symbol Table" (Elf32_Sym layout)
local M = {}
-- ════════════════════════════════════════════════════════════════════════════
-- Little-endian readers (bit-weighted accumulator, math.floor only)
-- ════════════════════════════════════════════════════════════════════════════
--- Read a 4-byte little-endian unsigned integer from `adapter` at zero-based wire offset `off`.
---
--- Bit weights are written as `0x100`, `0x10000`, `0x1000000` (i.e. 2^8, 2^16, 2^24) so the LE byte positions are visually explicit:
--- byte 0 contributes its value directly;
--- byte 1 is shifted left by 8; byte 2 by 16; byte 3 by 24.
---
--- math.floor (not LuaJIT's `>>`) keeps the body portable across LuaJIT 2.0/2.1 and plain Lua 5.x. `string.byte` receives `+ 1` at the boundary.
---
--- **Call form:** explicit-pass. The reader receives `adapter` as the first positional argument and the offset as the second; no `self` is passed.
--- Test fixtures declare `function(offset) ... end` and the parsers call them via dot syntax `adapter.read_u8_at(off)`.
--- The colon form `adapter:read_u8_at(off)` would prepend the adapter table as `offset` and break the contract.
--- @param adapter table
--- @param off integer -- zero-based wire offset
--- @return integer|nil
function M.read_u32(adapter, off)
return adapter.read_u8_at(off)
+ adapter.read_u8_at(off + 0x01) * 0x00000100
+ adapter.read_u8_at(off + 0x02) * 0x00010000
+ adapter.read_u8_at(off + 0x03) * 0x01000000
end
--- Read a 2-byte little-endian unsigned integer from `adapter` at zero-based wire offset `off`.
--- @param adapter table
--- @param off integer -- zero-based wire offset
--- @return integer|nil
function M.read_u16(adapter, off)
return adapter.read_u8_at(off)
+ adapter.read_u8_at(off + 0x01) * 0x00000100
end
--- Read a 1-byte unsigned integer from `adapter` at zero-based wire offset `off`.
--- @param adapter table
--- @param off integer -- zero-based wire offset
--- @return integer|nil
function M.read_u8(adapter, off)
return adapter.read_u8_at(off)
end
--- Total adapter byte length.
--- @param adapter table
--- @return integer
function M.size(adapter)
return adapter.read_size()
end
--- Forwarders kept for backward compat with scripts/elf_dwarf.lua.
--- The metaprogram side keeps `read_u32_le` / `read_u16_le`;
--- both layers now use the same byte-level helpers under the hood.
function M.read_u32_le(buf, off)
local byte_off = off + 1
return buf:byte(byte_off)
+ buf:byte(byte_off + 0x01) * 0x00000100
+ buf:byte(byte_off + 0x02) * 0x00010000
+ buf:byte(byte_off + 0x03) * 0x01000000
end
--- Read a 2-byte little-endian unsigned integer from `buf` at zero-based wire offset `off`.
--- @param buf string
--- @param off integer -- zero-based wire offset
--- @return integer
function M.read_u16_le(buf, off)
local byte_off = off + 1
return buf:byte(byte_off) + buf:byte(byte_off + 0x01) * 0x00000100
end
-- ════════════════════════════════════════════════════════════════════════════
-- Format constants
-- ════════════════════════════════════════════════════════════════════════════
-- ELF format constants (System V ABI gABI v1.2).
M.ELFCLASS32 = 1 -- spec: gABI v1.2 §"ELF Header" — EI_CLASS byte
M.ELFDATA2LSB = 1 -- spec: gABI v1.2 §"ELF Header" — EI_DATA byte
M.EM_MIPS = 8 -- spec: gABI v1.2 §"Machine Information" — MIPS architecture
-- Section type constants (System V ABI gABI v1.2 §"Section Header Table").
M.SHT_SYMTAB = 2 -- spec: gABI v1.2 §"Section Types" — symbol table
M.SHT_STRTAB = 3 -- spec: gABI v1.2 §"Section Types" — string table
M.SHT_NOBITS = 8 -- spec: gABI v1.2 §"Section Types" — no space in file
-- Section flag constants (System V ABI gABI v1.2 §"Section Header Table").
M.SHF_WRITE = 0x1 -- spec: gABI v1.2 §"Section Attributes" — writable
M.SHF_ALLOC = 0x2 -- spec: gABI v1.2 §"Section Attributes" — occupies memory
M.SHF_EXECINSTR = 0x4 -- spec: gABI v1.2 §"Section Attributes" — executable
-- ---------------------------------------------------------------------------
-- ELF32 header layout (System V ABI gABI v1.2 §"ELF Header" Table 1)
-- ---------------------------------------------------------------------------
-- All offsets are zero-based wire offsets. The header is 52 bytes total (header_bytes = 0x34 = 52).
M.ELF32_HEADER = {
magic_offset = 0x00, -- 4 bytes; expected "\127ELF"
magic = "\127ELF",
class_offset = 0x04, -- 1 byte; 1 = ELF32, 2 = ELF64
endian_offset = 0x05, -- 1 byte; 1 = little-endian, 2 = big-endian
header_bytes = 0x34, -- ELF32 header is 52 bytes total
e_entry_offset = 0x18, -- 4-byte LE; entry-point virtual address
e_shoff_offset = 0x20, -- 4-byte LE; section-header table file offset
e_shentsize_offset = 0x2E, -- 2-byte LE; section-header entry size in bytes
e_shnum_offset = 0x30, -- 2-byte LE; number of section headers
e_shstrndx_offset = 0x32, -- 2-byte LE; index of section-name string table
}
-- ---------------------------------------------------------------------------
-- ELF32 section-header layout (System V ABI gABI v1.2 §"Section Header Table")
-- ---------------------------------------------------------------------------
-- Each entry is 40 bytes (sh_entsize_bytes = 0x28 = 40);
-- zero-based, field offsets relative to the start of the entry.
M.ELF32_SECTION = {
sh_name_offset = 0x00, -- 4-byte LE; offset into .shstrtab
sh_type_offset = 0x04, -- 4-byte LE; section type (SHT_*)
sh_flags_offset = 0x08, -- 4-byte LE; section flags (SHF_*)
sh_addr_offset = 0x0C, -- 4-byte LE; virtual address at execution
sh_offset_offset = 0x10, -- 4-byte LE; section's file offset
sh_size_offset = 0x14, -- 4-byte LE; section's size in bytes
sh_link_offset = 0x18, -- 4-byte LE; link to a related section
sh_entsize_bytes = 0x28, -- spec: gABI v1.2 §"Section Header Table" — 40 bytes per entry
}
-- ---------------------------------------------------------------------------
-- ELF32 symbol-table entry layout (System V ABI gABI v1.2 §"Symbol Table")
-- ---------------------------------------------------------------------------
-- Each entry is 16 bytes (sym_entry_bytes = 0x10 = 16);
-- zero-based, field offsets relative to the start of the entry.
M.ELF32_SYM = {
st_name = 0x00, -- 4-byte LE; offset into the linked string table
st_value = 0x04, -- 4-byte LE; symbol value (address / absolute)
st_size = 0x08, -- 4-byte LE; symbol size in bytes
st_info = 0x0C, -- 1 byte; binding (high nibble) + type (low nibble)
sym_entry_bytes = 0x10, -- spec: gABI v1.2 §"Symbol Table" — 16 bytes per entry
}
-- DWARF32 initial-length terminator (DWARF4 §7.4) — kept here so the metaprogram's elf_dwarf.lua can drop its own copy of the same constant.
M.dw_dwarf32_terminator = 0xFFFFFFFF
-- ════════════════════════════════════════════════════════════════════════════
-- Adapter validation
-- ════════════════════════════════════════════════════════════════════════════
--- Validate that `adapter` exposes the byte-read surface.
--- Returns true on success, false + a stable error code on failure.
--- The helper side calls this before parse_manifest to reject callers before any byte is read.
--- @param adapter any
--- @return boolean, string|nil
function M.validate_adapter(adapter)
if type(adapter) ~= "table" then return false, "bad_file_adapter" end
if type(adapter.read_u8_at) ~= "function" then return false, "bad_file_adapter" end
if type(adapter.read_u16_at) ~= "function" then return false, "bad_file_adapter" end
if type(adapter.read_u32_at) ~= "function" then return false, "bad_file_adapter" end
if type(adapter.read_size) ~= "function" then return false, "bad_file_adapter" end
return true, nil
end
-- ════════════════════════════════════════════════════════════════════════════
-- String-table reader
-- ════════════════════════════════════════════════════════════════════════════
--- Extract a NUL-terminated C string from `strtab` at zero-based offset `off`.
--- Returns nil if `off` is out of range or the string is not NUL-terminated.
--- @param strtab string
--- @param off integer
--- @return string|nil
function M.get_str(strtab, off)
if off < 0 or off >= #strtab then return nil end
local end_pos = strtab:find("\0", off + 1, true)
if not end_pos then return nil end
return strtab:sub(off + 1, end_pos - 1)
end
-- ════════════════════════════════════════════════════════════════════════════
-- Header / section / symbol walkers
-- ════════════════════════════════════════════════════════════════════════════
--- Read the ELF32 header through `adapter` and validate the magic, class, and data encoding.
--- Returns a table on success:
--- { e_entry, e_shoff, e_shentsize, e_shnum, e_shstrndx, error = nil }
--- On failure returns nil + a stable error code:
--- bad_magic, unsupported_elf_class, unsupported_elf_data, truncated_header
--- The header's machine field is NOT validated here — callers (e.g. the helper's prime path) decide whether to require EM_MIPS before symbol reads.
--- @param adapter table
--- @return table|nil, string|nil
function M.parse_elf32_headers(adapter)
local ok, err = M.validate_adapter(adapter)
if not ok then return nil, err end
-- 4-byte magic: 0x7F 'E' 'L' 'F'.
-- The byte readers take the adapter explicitly.
-- The production `Support.File` adapter is wrapped by the caller to drop its implicit `self` so the parser shape is flat pass-style.
local b1 = M.read_u8(adapter, 0)
local b2 = M.read_u8(adapter, 1)
local b3 = M.read_u8(adapter, 2)
local b4 = M.read_u8(adapter, 3)
if not (b1 and b2 and b3 and b4)
or not (b1 == 0x7f and b2 == 0x45 and b3 == 0x4c and b4 == 0x46) then
return nil, "bad_magic"
end
local class = M.read_u8(adapter, M.ELF32_HEADER.class_offset)
if class ~= M.ELFCLASS32 then
return nil, "unsupported_elf_class"
end
local data = M.read_u8(adapter, M.ELF32_HEADER.endian_offset)
if data ~= M.ELFDATA2LSB then
return nil, "unsupported_elf_data"
end
local e_entry = M.read_u32(adapter, M.ELF32_HEADER.e_entry_offset)
local e_shoff = M.read_u32(adapter, M.ELF32_HEADER.e_shoff_offset)
local e_shentsize = M.read_u16(adapter, M.ELF32_HEADER.e_shentsize_offset)
local e_shnum = M.read_u16(adapter, M.ELF32_HEADER.e_shnum_offset)
local e_shstrndx = M.read_u16(adapter, M.ELF32_HEADER.e_shstrndx_offset)
if not (e_entry and e_shoff and e_shentsize and e_shnum and e_shstrndx) then
return nil, "truncated_header"
end
return {
e_entry = e_entry,
e_shoff = e_shoff,
e_shentsize = e_shentsize,
e_shnum = e_shnum,
e_shstrndx = e_shstrndx,
error = nil,
}
end
--- Read one section-header entry from `adapter` at `sh_off`.
--- Returns a table with the wire fields plus a (yet-unresolved) `name` field.
--- @param adapter table
--- @param sh_off integer
--- @return table|nil, string|nil -- entry, error
local function read_section_entry(adapter, sh_off)
local entry = {
sh_name = M.read_u32(adapter, sh_off + M.ELF32_SECTION.sh_name_offset),
sh_type = M.read_u32(adapter, sh_off + M.ELF32_SECTION.sh_type_offset),
sh_flags = M.read_u32(adapter, sh_off + M.ELF32_SECTION.sh_flags_offset),
sh_addr = M.read_u32(adapter, sh_off + M.ELF32_SECTION.sh_addr_offset),
sh_offset = M.read_u32(adapter, sh_off + M.ELF32_SECTION.sh_offset_offset),
sh_size = M.read_u32(adapter, sh_off + M.ELF32_SECTION.sh_size_offset),
sh_link = M.read_u32(adapter, sh_off + M.ELF32_SECTION.sh_link_offset),
name = "",
}
if not (entry.sh_name and entry.sh_type and entry.sh_flags and entry.sh_addr
and entry.sh_offset and entry.sh_size and entry.sh_link) then
return nil, "truncated_section_headers"
end
return entry, nil
end
--- Walk every section header in `hdr` and return a 1-based array of entries
--- (the section at logical index 0 is at array position 1, etc.).
--- Each entry has the wire fields plus a resolved `name` derived from `.shstrtab`.
--- Returns nil + a stable error code on failure: truncated_section_headers, missing_shstrtab, truncated_strtab
--- @param adapter table
--- @param hdr table -- the table returned by parse_elf32_headers
--- @return table|nil, string|nil
function M.walk_sections(adapter, hdr)
if not hdr or hdr.error then return nil, hdr and hdr.error or "truncated_section_headers" end
local file_size = M.size(adapter)
if hdr.e_shoff + hdr.e_shnum * hdr.e_shentsize > file_size then
return nil, "truncated_section_headers"
end
-- Read every section header first; we need .shstrtab to resolve names.
local sections = {}
for i = 0, hdr.e_shnum - 1 do
local sh_off = hdr.e_shoff + i * hdr.e_shentsize
local entry, err = read_section_entry(adapter, sh_off)
if not entry then return nil, err end
sections[i + 1] = entry
end
if hdr.e_shstrndx >= hdr.e_shnum then
return nil, "missing_shstrtab"
end
local shstrtab = sections[hdr.e_shstrndx + 1]
if not shstrtab or shstrtab.sh_type ~= M.SHT_STRTAB then
return nil, "missing_shstrtab"
end
if shstrtab.sh_offset + shstrtab.sh_size > file_size then
return nil, "truncated_section_headers"
end
local shstrtab_bytes = M.read_section_bytes(adapter, shstrtab)
if not shstrtab_bytes then return nil, "truncated_section_headers" end
for _, s in ipairs(sections) do
s.name = M.get_str(shstrtab_bytes, s.sh_name) or ""
end
return sections, nil
end
--- Read the bytes of one section. Returns a string, or nil if the adapter returns nil for any byte (out-of-bounds).
--- The caller is responsible fors sizing the buffer (the section's sh_offset + sh_size must fit in adapter.size).
--- @param adapter table
--- @param section table -- one entry from walk_sections
--- @return string|nil
function M.read_section_bytes(adapter, section)
local size = section.sh_size
if size == 0 then return "" end
local out = {}
for i = 0, size - 1 do
local b = M.read_u8(adapter, section.sh_offset + i)
if b == nil then return nil end
out[#out + 1] = string.char(b)
end
return table.concat(out)
end
--- Convenience: walk sections, then look up the named section, then read its bytes.
--- Returns nil + a stable error code if the section is absent or out-of-bounds.
--- @param adapter table
--- @param sections table -- 1-based array from walk_sections
--- @param name string
--- @return string|nil, string|nil
function M.read_named_section(adapter, sections, name)
if not sections then return nil, "missing_section" end
for _, s in ipairs(sections) do
if s.name == name then
local bytes = M.read_section_bytes(adapter, s)
if not bytes then return nil, "truncated_section_data" end
return bytes, nil
end
end
return nil, "missing_section"
end
--- Walk every SHT_SYMTAB section in `sections` and accumulate symbols by name.
--- Each stored entry is `{ value = st_value, size = st_size, info = st_info, shndx = st_shndx }`.
--- Both STB_LOCAL and STB_GLOBAL symbols are included; the live ELF stores `smem` as a local symbol.
--- Returns nil + a stable error code on failure: missing_symtab_strtab, truncated_section_headers
--- @param adapter table
--- @param sections table
--- @return table|nil, string|nil
function M.collect_symbols(adapter, sections)
if not sections then return nil, "missing_sections" end
local symbols = {}
local file_size = M.size(adapter)
for _, s in ipairs(sections) do
if s.sh_type == M.SHT_SYMTAB then
local strtab = sections[s.sh_link + 1]
if not strtab or strtab.sh_type ~= M.SHT_STRTAB then
return nil, "missing_symtab_strtab"
end
if strtab.sh_offset + strtab.sh_size > file_size then
return nil, "truncated_section_headers"
end
local strtab_bytes = M.read_section_bytes(adapter, strtab)
if not strtab_bytes then return nil, "truncated_section_headers" end
if s.sh_offset + s.sh_size > file_size then
return nil, "truncated_section_headers"
end
local symtab_bytes = M.read_section_bytes(adapter, s)
if not symtab_bytes then return nil, "truncated_section_headers" end
local n = #symtab_bytes / M.ELF32_SYM.sym_entry_bytes
for j = 0, n - 1 do
local e = s.sh_offset + j * M.ELF32_SYM.sym_entry_bytes
local st_name = M.read_u32(adapter, e + M.ELF32_SYM.st_name)
if st_name then
local st_value = M.read_u32(adapter, e + M.ELF32_SYM.st_value)
local st_size = M.read_u32(adapter, e + M.ELF32_SYM.st_size)
local st_info = M.read_u8(adapter, e + M.ELF32_SYM.st_info)
-- st_shndx is at offset 14 (2 bytes) — derived from the layout
-- the metaprogram reads too. Inline the read to keep the
-- adapter as the only I/O surface.
local b1 = M.read_u8(adapter, e + 14)
local b2 = M.read_u8(adapter, e + 15)
if not (b1 and b2) then
return nil, "truncated_section_headers"
end
local st_shndx = b1 + b2 * 0x100
local name = M.get_str(strtab_bytes, st_name) or ""
if name ~= "" then
symbols[name] = {
value = st_value,
size = st_size,
info = st_info,
shndx = st_shndx,
}
end
end
end
end
end
return symbols, nil
end
return M