From 870420e6689b080415214b8366e3acb0ef93522a Mon Sep 17 00:00:00 2001 From: BinFlip Date: Sat, 26 Sep 2026 17:07:04 -0700 Subject: [PATCH] chore: release 0.2.0 Add `signature` (procedure export declarations and DLL import declarations) and `frame` (per-instruction stack heights and the slots each call consumes), plus `Opcode::operands()` and `Operand::var_refs()`. Fix the decoding of `cm_spc`, which carries two variables rather than a u32, and the coordinates of `flaggoto` and the `try` handler sections, which are deltas from the end of the instruction rather than procedure-local absolutes. Unknown opcode and sub-operation bytes now report their own errors. These change the public API (`ProcDisasm::branch_target()` / `branch_targets()` are removed, `FlagGoto`'s field is renamed and `SetStackPointerToCopy` gains a second operand), so this is a minor bump rather than 0.1.4. --- CHANGELOG.md | 13 ++ Cargo.toml | 2 +- src/bytecode.rs | 192 +++++++++------------------- src/disasm.rs | 61 ++++----- src/error.rs | 38 ++++++ src/frame.rs | 311 +++++++++++++++++++++++++++++++++++++++++++++ src/header.rs | 7 +- src/lib.rs | 4 + src/opcode.rs | 130 ++++++++++++++++--- src/operand.rs | 15 +++ src/proc.rs | 36 +++++- src/signature.rs | 321 +++++++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 941 insertions(+), 189 deletions(-) create mode 100644 src/frame.rs create mode 100644 src/signature.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0368041..8c56fc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to this crate are documented here. +## 0.2.0 + +This release changes the public API: `ProcDisasm::branch_target()` / `branch_targets()` are removed, `FlagGoto`'s field is renamed, and `SetStackPointerToCopy` now carries two operands. Hence the minor version bump. + +- Added `signature`: `Signature` parses an internal procedure's export declaration (`InternalProc::signature()`) and an external one's bit string, and `ExternalDecl` a DLL import's library, function, calling convention and loading flags (`ExternalProc::declaration()`). `Signature::slot` says what a stack slot holds inside the procedure: the return address, the result reference, a parameter, or a local. +- Added `frame`: `StackMap` (`ProcDisasm::stack_map()`) gives the stack height before every instruction, following the body's control flow and restoring the height a `try` saved at each of its handler sections, and refuses a body whose paths disagree about it (`Error::StackImbalance`). `StackMap::call_slots` gives the slots a call consumes, from the callee's declaration or, for the compiler's intrinsics that declare no parameters, from the pops that follow the call. Checked on a compiled script: the two agree on every call to an internal procedure. +- Added `Opcode::operands()` and `Operand::var_refs()`: every operand an instruction carries, and every variable an operand names. +- Fixed `cm_spc` (`SetStackPointerToCopy`, opcode 22). It carries two variables, a destination pointer and the value it is made to point at a copy of, as the runtime reads them and the compiler writes them. It was decoded as a `u32`, which left the decoder mid-operand, so any procedure containing it failed to decode. The variant is now `SetStackPointerToCopy { dest, src }`. +- Fixed the coordinates of `flaggoto` and the four `PushExceptionHandler` sections. Like every other IFPS branch, they are deltas from the end of the instruction (the runtime adds the position after the operands), not procedure-local absolutes. On `imagemagick-setup.exe` the 0.1.2 reading put 10 of 14 `flaggoto` targets between instructions, and on a compiled `try` fixture all 4 exception sections. `Instruction::branch_targets()` now returns every form in IFPS-blob coordinates, so `ProcDisasm::branch_target()` / `branch_targets()` have no rebasing left to do and are removed. `FlagGoto`'s field is renamed `offset` to say it is a delta. +- An exception-handler section the `try` does not have (`INVALID_VAL` on the wire) is now `None` rather than a target one byte before the instruction. +- `FlagGoto` is a conditional branch in `Opcode::flow_type()`: it branches only when the saved flag is set. +- An unknown opcode byte is `Error::UnknownOpcode` and an unknown calculation, comparison or exception-section byte is `Error::UnknownSubOp`, both of which previously reported `Error::UnknownBaseType`. + ## 0.1.3 - Replaced em-dashes and en-dashes in rustdoc comments and the changelog with plain ASCII hyphens, so generated documentation renders consistently. diff --git a/Cargo.toml b/Cargo.toml index 1d48ea3..5ec86e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pascalscript" -version = "0.1.3" +version = "0.2.0" edition = "2024" rust-version = "1.85" description = "Read-only parser + disassembler for the RemObjects PascalScript III binary container format (IFPS)" diff --git a/src/bytecode.rs b/src/bytecode.rs index bfeb8ce..0fd5dd6 100644 --- a/src/bytecode.rs +++ b/src/bytecode.rs @@ -10,6 +10,7 @@ use crate::{ error::Error, + header::INVALID_VAL, opcode::{Opcode, parse_opcode}, reader::Reader, ty::Type, @@ -40,60 +41,45 @@ impl Instruction<'_> { /// Conditional opcodes return their taken target. Exception-handler /// setup opcodes carry multiple targets, so callers that need all of /// them should use [`Self::branch_targets`]. - /// - /// Carries the same coordinate caveat as [`Self::branch_targets`] - prefer - /// [`ProcDisasm::branch_target`], which normalizes every form to IFPS-blob - /// coordinates. pub fn branch_target(&self) -> Option { self.branch_targets().into_iter().flatten().next() } - /// Returns all explicit control-flow targets carried by this instruction. - /// - /// # Coordinates - /// - /// **The returned values are not all in the same coordinate system**, because - /// an [`Instruction`] does not know which procedure owns it: + /// Returns all explicit control-flow targets carried by this instruction, + /// in IFPS-blob coordinates - the same coordinate system as + /// [`Self::offset`], so a target can be compared directly against the + /// instruction stream. /// - /// - Relative branches (`goto`, `condgoto`, `condnotgoto`, the pop-and-goto - /// forms) resolve against [`Self::next_offset`], which is an IFPS-blob - /// offset - so these come back **blob-absolute**. A delta is - /// coordinate-independent, so this is exact. - /// - `flaggoto` and the four `PushExceptionHandler` regions store an - /// *absolute* position, and IFPS positions are **procedure-local** (the VM - /// indexes into the proc's own bytecode body). These come back exactly as - /// stored, i.e. relative to the owning proc's `bytecode_offset`. + /// Every IFPS branch stores a delta from the end of its instruction, and + /// the runtime adds it to the position after the operands: the gotos, the + /// pop-and-gotos, `flaggoto`, and each of the four exception-handler + /// sections. A delta is coordinate-independent, so resolving it against + /// [`Self::next_offset`] is exact. An exception-handler section the `try` + /// does not have is [`INVALID_VAL`] on the wire and `None` here. /// - /// Use [`ProcDisasm::branch_targets`] to get every form in blob - /// coordinates; it knows the proc base and applies it to the absolute forms - /// only. + /// The four slots are the handler's finally, except, second-finally and + /// end-of-block sections; a branch uses the first. pub fn branch_targets(&self) -> [Option; 4] { + let at = |delta: u32| Some(relative_target(self.next_offset, delta)); + let section = + |delta: u32| (delta != INVALID_VAL).then(|| relative_target(self.next_offset, delta)); match &self.opcode { Opcode::Goto { offset } | Opcode::PopAndGoto { offset } - | Opcode::Pop2AndGoto { offset } => [ - Some(relative_target(self.next_offset, *offset as u32)), - None, - None, - None, - ], - Opcode::CondGoto { offset, .. } | Opcode::CondNotGoto { offset, .. } => [ - Some(relative_target(self.next_offset, *offset)), - None, - None, - None, - ], - Opcode::FlagGoto { target } => [Some(*target), None, None, None], + | Opcode::Pop2AndGoto { offset } => [at(*offset as u32), None, None, None], + Opcode::CondGoto { offset, .. } + | Opcode::CondNotGoto { offset, .. } + | Opcode::FlagGoto { offset } => [at(*offset), None, None, None], Opcode::PushExceptionHandler { finally_offset, exception_offset, finally2_offset, end_of_block, } => [ - Some(*finally_offset), - Some(*exception_offset), - Some(*finally2_offset), - Some(*end_of_block), + section(*finally_offset), + section(*exception_offset), + section(*finally2_offset), + section(*end_of_block), ], _ => [None, None, None, None], } @@ -102,8 +88,9 @@ impl Instruction<'_> { /// Resolves a PC-relative branch delta against the position it is relative to. /// -/// Every relative branch in IFPS (`Cm_G`, `Cm_CG`, `Cm_CNG`, and the two -/// pop-and-goto forms) encodes the same thing: a four-byte little-endian delta +/// Every IFPS branch (`Cm_G`, `Cm_CG`, `Cm_CNG`, `cm_fg`, the two +/// pop-and-goto forms and the exception-handler sections) encodes the same +/// thing: a four-byte little-endian delta /// added to the position after the instruction. The VM performs that addition /// on Delphi `Cardinal`s (`CurrentPosition := CurrentPosition + NewPosition`), /// which wraps - so the compiler emits a **backward** branch as a large @@ -138,44 +125,6 @@ pub struct ProcDisasm<'a> { pub instructions: Vec>, } -impl ProcDisasm<'_> { - /// Returns `instruction`'s primary control-flow target in IFPS-blob - /// coordinates, or `None` for a non-branch opcode. - /// - /// See [`Self::branch_targets`] for why this exists alongside - /// [`Instruction::branch_target`]. - pub fn branch_target(&self, instruction: &Instruction<'_>) -> Option { - self.branch_targets(instruction) - .into_iter() - .flatten() - .next() - } - - /// Returns every control-flow target `instruction` carries, all of them in - /// **IFPS-blob coordinates** - the same coordinate system as - /// [`Instruction::offset`], so a target can be compared directly against - /// the instruction stream. - /// - /// [`Instruction::branch_targets`] cannot do this: `flaggoto` and the - /// `PushExceptionHandler` regions store procedure-local positions, and an - /// instruction does not know its procedure. This method adds - /// [`Self::bytecode_offset`] to exactly those forms and leaves the - /// already-blob-absolute relative branches alone. - /// - /// `instruction` is expected to belong to this procedure; passing a foreign - /// one rebases its absolute targets against the wrong proc. - pub fn branch_targets(&self, instruction: &Instruction<'_>) -> [Option; 4] { - let raw = instruction.branch_targets(); - if !matches!( - instruction.opcode, - Opcode::FlagGoto { .. } | Opcode::PushExceptionHandler { .. } - ) { - return raw; - } - raw.map(|target| target.map(|t| self.bytecode_offset.wrapping_add(t))) - } -} - /// Decodes the bytecode body for an internal proc. /// /// `blob` is the entire IFPS byte buffer; `bytecode_offset` and @@ -306,39 +255,37 @@ mod tests { assert_eq!(unconditional.branch_target(), conditional.branch_target()); } - /// `flaggoto` carries an absolute position, not a delta - and on the bare - /// instruction it comes back procedure-local, as stored. + /// `flaggoto` carries a delta from the end of the instruction, like every + /// other branch: the runtime adds it to the position after the operand. + /// Read as a procedure-local absolute (the 0.1.2 reading) it resolved to a + /// boundary for 4 of the 14 in `imagemagick-setup.exe`; as a delta, 14. #[test] - fn flag_goto_target_is_absolute() { - let inst = at(0x0100, Opcode::FlagGoto { target: 0x40 }); - assert_eq!(inst.branch_target(), Some(0x40)); - } - - /// Builds a single-instruction [`ProcDisasm`] based at `bytecode_offset`. - fn proc(bytecode_offset: u32, inst: Instruction<'static>) -> ProcDisasm<'static> { - ProcDisasm { - proc_index: 0, - bytecode_offset, - instructions: vec![inst], - } + fn flag_goto_target_is_relative_to_the_next_instruction() { + let inst = at(0x1100, Opcode::FlagGoto { offset: 0x40 }); + assert_eq!(inst.branch_target(), Some(0x1140)); + let back = at( + 0x1100, + Opcode::FlagGoto { + offset: (-0x40i32) as u32, + }, + ); + assert_eq!(back.branch_target(), Some(0x10C0), "a backward delta wraps"); } - /// The proc-level resolver rebases procedure-local absolute targets onto - /// the proc's blob offset, so they can be compared against instruction - /// offsets from the same listing. + /// Non-branch opcodes carry no targets at all. #[test] - fn proc_level_resolution_rebases_absolute_targets() { - let inst = at(0x1100, Opcode::FlagGoto { target: 0x40 }); - let disasm = proc(0x1000, inst.clone()); - assert_eq!(inst.branch_target(), Some(0x40), "raw form stays as stored"); - assert_eq!(disasm.branch_target(&inst), Some(0x1040)); + fn non_branch_opcodes_have_no_targets() { + let inst = at(0x0100, Opcode::Return); + assert_eq!(inst.branch_target(), None); + assert_eq!(inst.branch_targets(), [None, None, None, None]); } - /// All four exception-handler regions are rebased. + /// The exception-handler setup opcode carries four targets, each a delta + /// from the end of the instruction; `branch_target` surfaces the first. #[test] - fn proc_level_resolution_rebases_every_handler_region() { + fn exception_handler_carries_every_region_target() { let inst = at( - 0x1100, + 0x0100, Opcode::PushExceptionHandler { finally_offset: 0x10, exception_offset: 0x20, @@ -346,48 +293,31 @@ mod tests { end_of_block: 0x40, }, ); - let disasm = proc(0x1000, inst.clone()); assert_eq!( - disasm.branch_targets(&inst), - [Some(0x1010), Some(0x1020), Some(0x1030), Some(0x1040)], + inst.branch_targets(), + [Some(0x0110), Some(0x0120), Some(0x0130), Some(0x0140)], ); + assert_eq!(inst.branch_target(), Some(0x0110)); } - /// Relative branches are already blob-absolute, so the proc-level resolver - /// must leave them untouched rather than double-adding the proc base. - #[test] - fn proc_level_resolution_leaves_relative_targets_alone() { - let inst = at(0x1100, Opcode::Goto { offset: -0x40 }); - let disasm = proc(0x1000, inst.clone()); - assert_eq!(disasm.branch_target(&inst), inst.branch_target()); - assert_eq!(disasm.branch_target(&inst), Some(0x10C0)); - } - - /// Non-branch opcodes carry no targets at all. - #[test] - fn non_branch_opcodes_have_no_targets() { - let inst = at(0x0100, Opcode::Return); - assert_eq!(inst.branch_target(), None); - assert_eq!(inst.branch_targets(), [None, None, None, None]); - } - - /// The exception-handler setup opcode carries four targets; `branch_target` - /// surfaces the first. + /// A section the `try` does not have is `INVALID_VAL` on the wire, which + /// the runtime skips rather than adds; it is no target at all, where + /// resolving it as a delta would land one byte before the instruction. #[test] - fn exception_handler_carries_every_region_target() { + fn an_absent_handler_section_is_no_target() { let inst = at( 0x0100, Opcode::PushExceptionHandler { - finally_offset: 0x10, + finally_offset: INVALID_VAL, exception_offset: 0x20, - finally2_offset: 0x30, + finally2_offset: INVALID_VAL, end_of_block: 0x40, }, ); assert_eq!( inst.branch_targets(), - [Some(0x10), Some(0x20), Some(0x30), Some(0x40)], + [None, Some(0x0120), None, Some(0x0140)], ); - assert_eq!(inst.branch_target(), Some(0x10)); + assert_eq!(inst.branch_target(), Some(0x0120)); } } diff --git a/src/disasm.rs b/src/disasm.rs index 2f56df2..758481f 100644 --- a/src/disasm.rs +++ b/src/disasm.rs @@ -5,14 +5,12 @@ //! Output is a per-instruction listing: 4-digit hex offset, //! mnemonic, operand list. Names come from the type / proc / var //! tables when available, with `g#N`, `s+N`, `proc#N`, `type#N` -//! fallbacks for unnamed entries. PC-relative branch targets -//! (`Goto`, `PopAndGoto`, `Pop2AndGoto`) are resolved to absolute -//! offsets via [`Instruction::next_offset`]; absolute branches -//! (`FlagGoto`, the exception-handler regions) are stored -//! procedure-locally and are rebased onto the proc's -//! `bytecode_offset`, so every offset in a listing - the leading -//! instruction offset and every printed target alike - is in one -//! coordinate system. +//! fallbacks for unnamed entries. Every branch target - the gotos, +//! `FlagGoto` and the exception-handler sections - is a delta from +//! the end of its instruction, resolved via +//! [`Instruction::branch_targets`], so every offset in a listing (the +//! leading instruction offset and every printed target alike) is in +//! one coordinate system. //! //! Control-flow reconstruction (loop / if-else detection from //! goto patterns) is deferred - the raw target offsets in the @@ -126,7 +124,7 @@ impl fmt::Display for DisasmDisplay<'_, '_> { self.disasm.instructions.len(), )?; for inst in &self.disasm.instructions { - write_instruction(f, self.container, inst, self.disasm.bytecode_offset)?; + write_instruction(f, self.container, inst)?; } Ok(()) } @@ -143,18 +141,10 @@ fn format_proc_self_label(proc: &Proc<'_>) -> String { } /// Writes one instruction line. -/// -/// `bytecode_offset` is the owning proc's base in the IFPS blob. It is needed -/// because `flaggoto` and the exception-handler regions store *procedure-local* -/// absolute positions, whereas the leading `inst.offset` on every line - and -/// every relative branch target - is blob-absolute. Printing them as stored -/// would make a handler target unresolvable against the very listing it sits in -/// for every proc but the first. fn write_instruction( f: &mut fmt::Formatter<'_>, container: &Container<'_>, inst: &Instruction<'_>, - bytecode_offset: u32, ) -> fmt::Result { write!(f, " {:#06x} ", inst.offset)?; match &inst.opcode { @@ -240,33 +230,34 @@ fn write_instruction( if *invert { "not " } else { "" }, FormatOperand::new(container, var), ), - Opcode::FlagGoto { target } => writeln!( + Opcode::FlagGoto { offset } => writeln!( f, "flag-goto {:#06x}", - relative_target(bytecode_offset, *target), - ), - Opcode::PushExceptionHandler { - finally_offset, - exception_offset, - finally2_offset, - end_of_block, - } => writeln!( - f, - "try finally={:#06x} except={:#06x} finally2={:#06x} end={:#06x}", - relative_target(bytecode_offset, *finally_offset), - relative_target(bytecode_offset, *exception_offset), - relative_target(bytecode_offset, *finally2_offset), - relative_target(bytecode_offset, *end_of_block), + relative_target(inst.next_offset, *offset), ), + Opcode::PushExceptionHandler { .. } => { + let [finally, except, finally2, end] = + inst.branch_targets().map(|target| match target { + Some(target) => format!("{target:#06x}"), + None => "-".to_string(), + }); + writeln!( + f, + "try finally={finally} except={except} finally2={finally2} end={end}", + ) + } Opcode::PopExceptionHandler { position } => { writeln!(f, "end-try ({})", handler_end_name(*position),) } Opcode::IntegerNot { var } => { writeln!(f, "int-not {}", FormatOperand::new(container, var)) } - Opcode::SetStackPointerToCopy { target } => { - writeln!(f, "stack-copy {target:#06x}") - } + Opcode::SetStackPointerToCopy { dest, src } => writeln!( + f, + "stack-copy {} := copy {}", + FormatOperand::new(container, dest), + FormatOperand::new(container, src), + ), Opcode::Inc { var } => { writeln!(f, "inc {}", FormatOperand::new(container, var)) } diff --git a/src/error.rs b/src/error.rs index 2b386e4..c848794 100644 --- a/src/error.rs +++ b/src/error.rs @@ -44,6 +44,34 @@ pub enum Error { /// Raw byte read from the type record. byte: u8, }, + /// An opcode byte named no instruction the runtime executes + /// (`uPSUtils.pas:186-280`). + UnknownOpcode { + /// Raw opcode byte. + byte: u8, + }, + /// An instruction's sub-operation byte (a calculation, a + /// comparison, an exception-handler section) named none of its + /// kind. + UnknownSubOp { + /// Which sub-operation was being read. + what: &'static str, + /// Raw byte read. + byte: u8, + }, + /// A procedure declaration did not have the form its compiler writes + /// (see [`crate::signature`]). + MalformedDecl { + /// Which declaration form was being read. + what: &'static str, + }, + /// A procedure body's stack height disagreed between two paths into one + /// instruction, or a pop would go below the frame base (see + /// [`crate::frame`]). + StackImbalance { + /// Offset of the instruction where it happened. + offset: u32, + }, /// A type-table entry referenced an index outside the table. TypeIndexOutOfRange { /// Index that overflowed. @@ -83,6 +111,16 @@ impl fmt::Display for Error { Self::UnknownBaseType { byte } => { write!(f, "unknown PascalScript BaseType: 0x{byte:02x}") } + Self::UnknownOpcode { byte } => { + write!(f, "unknown PascalScript opcode: 0x{byte:02x}") + } + Self::UnknownSubOp { what, byte } => { + write!(f, "unknown PascalScript {what}: 0x{byte:02x}") + } + Self::MalformedDecl { what } => write!(f, "malformed {what}"), + Self::StackImbalance { offset } => { + write!(f, "stack height is inconsistent at {offset:#x}") + } Self::TypeIndexOutOfRange { index, count } => { write!(f, "type index {index} out of range (count={count})") } diff --git a/src/frame.rs b/src/frame.rs new file mode 100644 index 0000000..7acb99e --- /dev/null +++ b/src/frame.rs @@ -0,0 +1,311 @@ +//! The value stack a procedure's body works on: how deep it is before each +//! instruction, and which slots each call consumes. +//! +//! Every local, temporary and call argument lives on one stack, addressed from +//! the procedure's frame base: the body's own slots are `s+1` upward. Its +//! height changes by rule (`uPSRuntime.pas`): `PushType`, `Push` and `PushVar` +//! add a slot, `Pop` removes one, and the pop-and-goto forms remove one or two +//! before jumping. A call changes nothing: the caller pushes its arguments, +//! and pops them itself afterwards. The compiler keeps the height the same on +//! every path into an instruction, which is what lets a slot be named by its +//! offset at all - so a path that disagrees is a malformed procedure, not one +//! to guess about. +//! +//! An exception handler section runs at the height its `try` recorded: the +//! runtime saves the stack size at `PushExceptionHandler` and restores it on +//! entering the finally or except code (`uPSRuntime.pas`, `cm_puexh`). + +use std::collections::VecDeque; + +use crate::{ + bytecode::{Instruction, ProcDisasm}, + error::Error, + opcode::Opcode, +}; + +/// The stack height before each instruction of one procedure. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StackMap { + /// Height before instruction `i` (the number of the body's own slots + /// live), or `None` for an instruction no path reaches. + heights: Vec>, +} + +/// The stack slots one call consumes: the top `count` of the caller's. +/// +/// The callee sees them from its own frame base downward, the topmost as +/// `s-1` ([`Signature::slot`](crate::signature::Signature::slot) says what +/// each is to it). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CallSlots { + /// The lowest slot the call consumes, as the caller addresses it (`s+n`). + pub first: u32, + /// How many slots, up to the top of the caller's stack. + pub count: u32, +} + +impl CallSlots { + /// The caller's offset of the slot the callee addresses as `s-depth` + /// (`depth` from 1), or `None` below the call's slots. + #[must_use] + pub fn caller_offset(&self, depth: u32) -> Option { + if depth == 0 || depth > self.count { + return None; + } + self.first.checked_add(self.count)?.checked_sub(depth) + } +} + +impl StackMap { + /// Computes the height before each instruction of `disasm`. + /// + /// # Errors + /// + /// [`Error::StackImbalance`] when two paths reach an instruction at + /// different heights, or a pop would take the stack below the frame base. + pub fn of(disasm: &ProcDisasm<'_>) -> Result { + let instructions = &disasm.instructions; + let index_of = |offset: u32| instructions.iter().position(|i| i.offset == offset); + let mut heights: Vec> = vec![None; instructions.len()]; + let mut work = VecDeque::new(); + if !instructions.is_empty() { + work.push_back((0usize, 0u32)); + } + while let Some((index, height)) = work.pop_front() { + let Some(slot) = heights.get_mut(index) else { + continue; + }; + match *slot { + Some(known) if known == height => continue, + Some(_) => { + return Err(imbalance(instructions.get(index))); + } + None => *slot = Some(height), + } + let Some(instruction) = instructions.get(index) else { + continue; + }; + let after = height_after(instruction, height)?; + let fallthrough = index + .checked_add(1) + .filter(|next| *next < instructions.len()); + let mut go = |target: Option, height: u32| { + if let Some(target) = target.and_then(index_of) { + work.push_back((target, height)); + } + }; + match &instruction.opcode { + Opcode::Return => {} + Opcode::Goto { .. } | Opcode::PopAndGoto { .. } | Opcode::Pop2AndGoto { .. } => { + go(instruction.branch_target(), after); + } + Opcode::CondGoto { .. } | Opcode::CondNotGoto { .. } | Opcode::FlagGoto { .. } => { + go(instruction.branch_target(), after); + if let Some(next) = fallthrough { + work.push_back((next, after)); + } + } + Opcode::PushExceptionHandler { .. } => { + // Each section starts at the height the `try` saved. + for target in instruction.branch_targets() { + go(target, height); + } + if let Some(next) = fallthrough { + work.push_back((next, after)); + } + } + _ => { + if let Some(next) = fallthrough { + work.push_back((next, after)); + } + } + } + } + Ok(Self { heights }) + } + + /// The height before instruction `index`, or `None` when no path reaches + /// it. + #[must_use] + pub fn height_before(&self, index: usize) -> Option { + self.heights.get(index).copied().flatten() + } + + /// The slots the call at `index` consumes. + /// + /// `exact` is the count when the callee's declaration states every + /// parameter - an internal procedure's + /// ([`Signature::slot_count`](crate::signature::Signature::slot_count)). An + /// external one's cannot: the compiler's own intrinsics (`Length`, + /// `SetArrayLength` and the like) accept any type and list no parameters, + /// which reads the same as a host function that takes none. For those the + /// pops that follow the call decide, since the caller pops exactly what it + /// pushed for the call, and nothing else, right after it + /// (`uPSCompiler.pas`, `ProcessVarFunction`). `None` when the instruction + /// is not a call, is unreachable, or the count exceeds the stack. + #[must_use] + pub fn call_slots( + &self, + disasm: &ProcDisasm<'_>, + index: usize, + exact: Option, + ) -> Option { + let instruction = disasm.instructions.get(index)?; + if !matches!( + instruction.opcode, + Opcode::Call { .. } | Opcode::CallVar { .. } + ) { + return None; + } + let height = self.height_before(index)?; + let count = match exact { + Some(count) => count, + None => disasm + .instructions + .iter() + .skip(index.checked_add(1)?) + .take_while(|next| matches!(next.opcode, Opcode::Pop)) + .count(), + }; + let count = u32::try_from(count).ok()?; + let first = height.checked_sub(count)?.checked_add(1)?; + Some(CallSlots { first, count }) + } +} + +/// The height after `instruction` runs at `height`. +fn height_after(instruction: &Instruction<'_>, height: u32) -> Result { + let pop = |n: u32| { + height + .checked_sub(n) + .ok_or_else(|| imbalance(Some(instruction))) + }; + match instruction.opcode { + Opcode::PushType { .. } | Opcode::Push { .. } | Opcode::PushVar { .. } => height + .checked_add(1) + .ok_or_else(|| imbalance(Some(instruction))), + Opcode::Pop | Opcode::PopAndGoto { .. } => pop(1), + Opcode::Pop2AndGoto { .. } => pop(2), + _ => Ok(height), + } +} + +fn imbalance(instruction: Option<&Instruction<'_>>) -> Error { + Error::StackImbalance { + offset: instruction.map_or(0, |i| i.offset), + } +} + +impl ProcDisasm<'_> { + /// The stack height before each instruction ([`StackMap`]). + /// + /// # Errors + /// + /// [`Error::StackImbalance`] when the body's paths disagree about it. + pub fn stack_map(&self) -> Result { + StackMap::of(self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + operand::{Operand, VarRef}, + signature::Signature, + }; + + /// A body of `opcodes`, one byte each, from offset 0x100. + fn body(opcodes: Vec>) -> ProcDisasm<'static> { + let instructions = opcodes + .into_iter() + .zip(0x100u32..) + .map(|(opcode, offset)| Instruction { + offset, + opcode, + next_offset: offset + 1, + }) + .collect(); + ProcDisasm { + proc_index: 0, + bytecode_offset: 0x100, + instructions, + } + } + + fn slot(n: i64) -> Operand<'static> { + Operand::Var(VarRef::Stack(n)) + } + + /// `SCALE(6, 7)` as `code.iss` compiles it: two argument temporaries and a + /// result reference above one local, the call, then three pops. + #[test] + fn a_call_consumes_the_slots_its_declaration_names() { + let disasm = body(vec![ + Opcode::PushType { type_no: 11 }, // s+1 Total + Opcode::PushType { type_no: 11 }, // s+2 Factor = 7 + Opcode::PushType { type_no: 11 }, // s+3 Value = 6 + Opcode::PushVar { var: slot(1) }, // s+4 -> result into Total + Opcode::Call { proc_no: 2 }, + Opcode::Pop, + Opcode::Pop, + Opcode::Pop, + Opcode::Return, + ]); + let map = disasm.stack_map().unwrap(); + assert_eq!(map.height_before(4), Some(4)); + assert_eq!(map.height_before(8), Some(1)); + let scale = Signature::of_internal(b"11 @11 @11").unwrap(); + let call = map + .call_slots(&disasm, 4, Some(scale.slot_count())) + .unwrap(); + assert_eq!(call, CallSlots { first: 2, count: 3 }); + // The callee's s-1 is the result reference, s-2 the first parameter. + assert_eq!(call.caller_offset(1), Some(4)); + assert_eq!(call.caller_offset(2), Some(3)); + assert_eq!(call.caller_offset(3), Some(2)); + assert_eq!(call.caller_offset(4), None); + } + + /// An intrinsic declares no parameters; the pops after it count them. + #[test] + fn an_intrinsics_slots_are_the_pops_after_it() { + let disasm = body(vec![ + Opcode::PushType { type_no: 11 }, + Opcode::PushType { type_no: 0 }, + Opcode::Call { proc_no: 7 }, + Opcode::Pop, + Opcode::Pop, + Opcode::Return, + ]); + let map = disasm.stack_map().unwrap(); + assert_eq!( + map.call_slots(&disasm, 2, None), + Some(CallSlots { first: 1, count: 2 }) + ); + } + + /// Two paths reaching one instruction at different heights are a malformed + /// body, never averaged: a slot's offset would name two variables. + #[test] + fn paths_that_disagree_about_the_height_are_refused() { + let disasm = body(vec![ + Opcode::CondGoto { + offset: 1, // to 0x102, skipping the push + cond: slot(-1), + }, + Opcode::PushType { type_no: 11 }, + Opcode::Nop, + Opcode::Return, + ]); + assert_eq!( + disasm.stack_map(), + Err(Error::StackImbalance { offset: 0x102 }) + ); + let underflow = body(vec![Opcode::Pop, Opcode::Return]); + assert_eq!( + underflow.stack_map(), + Err(Error::StackImbalance { offset: 0x100 }) + ); + } +} diff --git a/src/header.rs b/src/header.rs index 12411b3..a33f8d0 100644 --- a/src/header.rs +++ b/src/header.rs @@ -38,9 +38,10 @@ pub const PS_LOW_BUILD_SUPPORT: u32 = 12; /// Source: `uPSUtils.pas:16` (`PSCurrentBuildNo = 23`). pub const PS_CURRENT_BUILD_NO: u32 = 23; -/// Sentinel value [`Header::main_proc_no`] uses when the blob has -/// no entry point. Source: `uPSRuntime.pas:3024` (`HDR.MainProcNo -/// <> InvalidVal`). +/// The runtime's `InvalidVal`: what [`Header::main_proc_no`] holds +/// when the blob has no entry point (`uPSRuntime.pas:3024`), and what +/// an exception-handler section holds when the `try` has no such +/// section ([`crate::Opcode::PushExceptionHandler`]). pub const INVALID_VAL: u32 = u32::MAX; /// Total header size in bytes - `SizeOf(TPSHeader)` in upstream. diff --git a/src/lib.rs b/src/lib.rs index be65666..249b184 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -65,11 +65,13 @@ pub mod bytecode; pub mod container; pub mod disasm; pub mod error; +pub mod frame; pub mod header; pub mod literal; pub mod opcode; pub mod operand; pub mod proc; +pub mod signature; pub mod ty; pub mod var; @@ -78,10 +80,12 @@ pub use bytecode::{Instruction, ProcDisasm}; pub use container::Container; pub use disasm::{ContainerSummary, DisasmDisplay}; pub use error::Error; +pub use frame::{CallSlots, StackMap}; pub use header::{Header, IFPS_MAGIC, INVALID_VAL, PS_CURRENT_BUILD_NO, PS_LOW_BUILD_SUPPORT}; pub use literal::Literal; pub use opcode::{CalcOp, CompareOp, ExceptionHandlerEnd, FlowType, Opcode}; pub use operand::{Operand, VarRef}; pub use proc::{ExternalProc, InternalProc, Proc, ProcKind}; +pub use signature::{DllImport, ExternalDecl, FrameSlot, Param, Signature}; pub use ty::{BaseType, Type, TypeBody}; pub use var::Var; diff --git a/src/opcode.rs b/src/opcode.rs index b2d7ff3..b16954e 100644 --- a/src/opcode.rs +++ b/src/opcode.rs @@ -44,7 +44,10 @@ impl CalcOp { 7 => Ok(Self::And), 8 => Ok(Self::Or), 9 => Ok(Self::Xor), - other => Err(Error::UnknownBaseType { byte: other }), + other => Err(Error::UnknownSubOp { + what: "CalcType", + byte: other, + }), } } } @@ -72,7 +75,10 @@ impl CompareOp { 3 => Ok(Self::Less), 4 => Ok(Self::NotEqual), 5 => Ok(Self::Equal), - other => Err(Error::UnknownBaseType { byte: other }), + other => Err(Error::UnknownSubOp { + what: "CompareType", + byte: other, + }), } } } @@ -98,7 +104,10 @@ impl ExceptionHandlerEnd { 1 => Ok(Self::EndOfFirstFinally), 2 => Ok(Self::EndOfExcept), 3 => Ok(Self::EndOfSecondFinally), - other => Err(Error::UnknownBaseType { byte: other }), + other => Err(Error::UnknownSubOp { + what: "exception-handler section", + byte: other, + }), } } } @@ -184,11 +193,21 @@ pub enum Opcode<'a> { /// `cm_sf = 17` - set the saved compare flag from `var`, /// optionally inverted. SetFlag { var: Operand<'a>, invert: bool }, - /// `cm_fg = 18` - unconditional goto if the saved compare - /// flag is set. Absolute target offset. - FlagGoto { target: u32 }, + /// `cm_fg = 18` - goto when the saved compare flag is set. + /// + /// `offset` is a delta from the end of the instruction, as every + /// IFPS branch's is: the runtime adds it to the position after + /// the operand (`uPSRuntime.pas`, `cm_fg`). + FlagGoto { offset: u32 }, /// `cm_puexh = 19` - push a four-section exception handler /// frame (try/finally/except/finally2). + /// + /// Each section is a delta from the end of the instruction, or + /// [`INVALID_VAL`](crate::INVALID_VAL) for a section the `try` does not have: the + /// compiler writes `0xFFFFFFFF` and patches in each section it + /// emits, and the runtime adds the position after the operands + /// to every section but an absent one (`uPSRuntime.pas`, + /// `cm_puexh`). PushExceptionHandler { finally_offset: u32, exception_offset: u32, @@ -200,8 +219,9 @@ pub enum Opcode<'a> { PopExceptionHandler { position: ExceptionHandlerEnd }, /// `cm_in = 21` - bitwise NOT in place. IntegerNot { var: Operand<'a> }, - /// `cm_spc = 22` - copy stack-pointer state. - SetStackPointerToCopy { target: u32 }, + /// `cm_spc = 22` - make the pointer `dest` point at a copy of + /// `src` (the compiler emits it to fill arrays of pointers). + SetStackPointerToCopy { dest: Operand<'a>, src: Operand<'a> }, /// `cm_inc = 23` - increment in place. Inc { var: Operand<'a> }, /// `cm_dec = 24` - decrement in place. @@ -214,7 +234,7 @@ pub enum Opcode<'a> { Nop, } -impl Opcode<'_> { +impl<'a> Opcode<'a> { /// Numeric opcode byte (the leading byte of the wire form). pub fn raw_byte(&self) -> u8 { match self { @@ -249,14 +269,39 @@ impl Opcode<'_> { } } + /// Every operand the instruction carries, in wire order. + #[must_use] + pub fn operands(&self) -> Vec<&Operand<'a>> { + match self { + Self::Assign { dest, src } + | Self::CalculateAssign { dest, src, .. } + | Self::SetPointer { dest, src } + | Self::SetStackPointerToCopy { dest, src } => vec![dest, src], + Self::Compare { into, lhs, rhs, .. } => vec![into, lhs, rhs], + Self::Push { var } + | Self::PushVar { var } + | Self::CallVar { var } + | Self::BoolNot { var } + | Self::Negate { var } + | Self::SetFlag { var, .. } + | Self::IntegerNot { var } + | Self::Inc { var } + | Self::Dec { var } => vec![var], + Self::CondGoto { cond, .. } | Self::CondNotGoto { cond, .. } => vec![cond], + _ => Vec::new(), + } + } + /// Returns this opcode's high-level control-flow category. pub fn flow_type(&self) -> FlowType { match self { - Self::Goto { .. } - | Self::FlagGoto { .. } - | Self::PopAndGoto { .. } - | Self::Pop2AndGoto { .. } => FlowType::Branch, - Self::CondGoto { .. } | Self::CondNotGoto { .. } => FlowType::ConditionalBranch, + Self::Goto { .. } | Self::PopAndGoto { .. } | Self::Pop2AndGoto { .. } => { + FlowType::Branch + } + // `FlagGoto` branches only when the saved flag is set. + Self::CondGoto { .. } | Self::CondNotGoto { .. } | Self::FlagGoto { .. } => { + FlowType::ConditionalBranch + } Self::Return => FlowType::Return, Self::PushExceptionHandler { .. } => FlowType::ExceptionHandler, _ => FlowType::Fallthrough, @@ -362,7 +407,7 @@ pub(crate) fn parse_opcode<'a>( Opcode::SetFlag { var, invert } } 18 => Opcode::FlagGoto { - target: reader.u32_le("FlagGoto Where")?, + offset: reader.u32_le("FlagGoto Where")?, }, 19 => Opcode::PushExceptionHandler { finally_offset: reader.u32_le("ExceptionHandler FinallyOffset")?, @@ -377,7 +422,8 @@ pub(crate) fn parse_opcode<'a>( var: parse_operand(reader, types)?, }, 22 => Opcode::SetStackPointerToCopy { - target: reader.u32_le("SetStackPointerToCopy Where")?, + dest: parse_operand(reader, types)?, + src: parse_operand(reader, types)?, }, 23 => Opcode::Inc { var: parse_operand(reader, types)?, @@ -392,7 +438,7 @@ pub(crate) fn parse_opcode<'a>( offset: reader.i32_le("Pop2AndGoto NewPosition")?, }, 255 => Opcode::Nop, - other => return Err(Error::UnknownBaseType { byte: other }), + other => return Err(Error::UnknownOpcode { byte: other }), }; Ok(op) } @@ -515,10 +561,58 @@ mod tests { ); } + /// An unknown opcode is reported as one, and an unknown sub-operation + /// names which kind it was; both used to read as an unknown base type. #[test] fn rejects_unknown_opcode() { let buf = vec![100u8]; let mut r = Reader::new(&buf); - assert!(parse_opcode(&mut r, &[]).is_err()); + assert_eq!( + parse_opcode(&mut r, &[]), + Err(Error::UnknownOpcode { byte: 100 }) + ); + + let buf = vec![1u8, 42]; + let mut r = Reader::new(&buf); + assert_eq!( + parse_opcode(&mut r, &[]), + Err(Error::UnknownSubOp { + what: "CalcType", + byte: 42, + }) + ); + } + + /// `cm_spc` carries two variables, as the runtime reads them (`vd`, then + /// `vs`). Read as a `u32` it consumed five bytes of the first operand and + /// left the decoder mid-operand: the next instruction came out as garbage + /// and the whole procedure was dropped. The control is that next + /// instruction, which decodes. + #[test] + fn set_stack_pointer_to_copy_reads_two_variables() { + let mut buf = vec![22u8]; + put_var(&mut buf, 0, 0x6000_0002); // dest = stack slot s+2 + put_var(&mut buf, 0, 3); // src = global #3 + buf.push(9); // Return + let mut r = Reader::new(&buf); + assert_eq!( + parse_opcode(&mut r, &[]).unwrap(), + Opcode::SetStackPointerToCopy { + dest: Operand::Var(VarRef::Stack(2)), + src: Operand::Var(VarRef::Global(3)), + }, + ); + assert_eq!(parse_opcode(&mut r, &[]).unwrap(), Opcode::Return); + } + + /// `FlagGoto` branches only when the saved flag is set, so its flow is + /// conditional; `Goto` is the unconditional one. + #[test] + fn flag_goto_is_a_conditional_branch() { + assert_eq!( + Opcode::FlagGoto { offset: 4 }.flow_type(), + FlowType::ConditionalBranch + ); + assert_eq!(Opcode::Goto { offset: 4 }.flow_type(), FlowType::Branch); } } diff --git a/src/operand.rs b/src/operand.rs index c018960..e8c1565 100644 --- a/src/operand.rs +++ b/src/operand.rs @@ -111,6 +111,21 @@ pub enum Operand<'a> { }, } +impl Operand<'_> { + /// Every variable the operand names: the variable itself, an indexed + /// operand's base, and a dynamic index's index variable. A literal names + /// none. + pub fn var_refs(&self) -> impl Iterator { + let (first, second) = match *self { + Self::Var(var) => (Some(var), None), + Self::Indexed { base, .. } => (Some(base), None), + Self::DynamicIndexed { base, index_var } => (Some(base), Some(index_var)), + Self::Literal { .. } => (None, None), + }; + first.into_iter().chain(second) + } +} + /// Reads one operand at `reader`'s cursor, advancing past it. pub(crate) fn parse_operand<'a>( reader: &mut Reader<'a>, diff --git a/src/proc.rs b/src/proc.rs index 74dab19..7311fe4 100644 --- a/src/proc.rs +++ b/src/proc.rs @@ -18,7 +18,13 @@ //! u32, export_name, decl_len: u32, decl])`. Both flavors may be //! followed by an attribute block when `Flags & 4` is set. -use crate::{attribute::Attribute, error::Error, header::INVALID_VAL, reader::Reader}; +use crate::{ + attribute::Attribute, + error::Error, + header::INVALID_VAL, + reader::Reader, + signature::{ExternalDecl, Signature}, +}; const FLAG_EXTERNAL: u8 = 0x01; const FLAG_EXPORTED: u8 = 0x02; @@ -83,6 +89,34 @@ pub struct InternalProc<'a> { pub export_decl: Option<&'a [u8]>, } +impl<'a> ExternalProc<'a> { + /// The procedure's declaration: its parameters, whether it returns a + /// value, and for a DLL import the library and function it names. + /// `None` when the entry carries no declaration. + /// + /// # Errors + /// + /// [`Error::MalformedDecl`] when the declaration is not one the compiler + /// writes. + pub fn declaration(&self) -> Option, Error>> { + self.decl.map(ExternalDecl::parse) + } +} + +impl InternalProc<'_> { + /// The procedure's signature, from its export declaration: the result + /// and parameter types, and so what each of its stack slots holds + /// ([`Signature::slot`]). `None` for a procedure compiled without one. + /// + /// # Errors + /// + /// [`Error::MalformedDecl`] when the declaration is not one the compiler + /// writes. + pub fn signature(&self) -> Option> { + self.export_decl.map(Signature::of_internal) + } +} + /// Parses one proc-table entry's body (no attribute block), /// advancing `reader`. The caller is expected to push the /// resulting [`Proc`] onto the proc table and **then** invoke diff --git a/src/signature.rs b/src/signature.rs new file mode 100644 index 0000000..c3541be --- /dev/null +++ b/src/signature.rs @@ -0,0 +1,321 @@ +//! Procedure signatures, and what each of a procedure's stack slots holds. +//! +//! An internal procedure's declaration is text the compiler writes with +//! `MakeExportDecl` (`uPSCompiler.pas:13924-13939`): the result's type number +//! (`-1` for a procedure), then one ` @` per in-parameter or ` !` +//! per `var` parameter. An external one is the bit string `DeclToBits` writes +//! (`uPSCompiler.pas:1864-1880`): a byte saying whether there is a result, then +//! one byte per parameter, `1` for `var`. A DLL import prefixes that with +//! `dll:\0\0`, a calling convention, and its delay-load and +//! altered-search-path flags (`uPSC_dll.pas:139`). +//! +//! The declaration also decides the frame. A caller pushes the parameters last +//! to first, then a reference to the result, then calls; the callee's frame +//! base is the return address it pushes (`uPSRuntime.pas:8327-8342`). So inside +//! the body `s-1` is the result, `s-2` the first parameter and so on - or, for +//! a procedure with no result, `s-1` the first parameter - and `s+1` upward +//! are the locals and temporaries the body pushes. [`Signature::slot`] says +//! which. + +use std::str; + +use crate::error::Error; + +/// A procedure's parameters and result. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Signature { + /// Whether the procedure returns a value (a Pascal function). + pub returns: bool, + /// The result's type number, when the declaration states it. An internal + /// procedure's does; an external one's bit string does not. + pub result_type: Option, + /// The parameters, first to last. + pub params: Vec, +} + +/// One parameter of a [`Signature`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Param { + /// A `var` parameter: the caller passes a reference, and a write through + /// it is a write to the caller's variable. + pub by_ref: bool, + /// The parameter's type number, when the declaration states it. + pub type_no: Option, +} + +/// What a stack slot, addressed relative to a procedure's frame base, holds +/// inside that procedure. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FrameSlot { + /// `s+0`: the return address the call pushed. + ReturnAddress, + /// The reference to the caller's result variable. + Result, + /// Parameter `n`, first to last. + Param(u32), + /// `s+n`: a local or temporary the body pushed, counted from 1. + Local(u32), + /// Below the parameters: nothing this procedure's declaration accounts for. + Beyond, +} + +/// A DLL function an external procedure imports. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DllImport<'a> { + /// The library, as written (`user32.dll`). + pub library: &'a [u8], + /// The exported function (`MessageBeep`). + pub function: &'a [u8], + /// The calling convention byte: `0` register, `1` pascal, `2` cdecl, + /// `3` stdcall, `4` safecall (`TPSCallingConvention`). + pub calling_convention: u8, + /// Whether the library loads on first call rather than at startup. + pub delay_load: bool, + /// Whether it loads with `LOAD_WITH_ALTERED_SEARCH_PATH`. + pub altered_search_path: bool, +} + +/// An external procedure's declaration. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalDecl<'a> { + /// The DLL function, for a `dll:` import; `None` for a host function. + pub dll: Option>, + /// The parameters and result, without types. + pub signature: Signature, +} + +impl Signature { + /// Parses an internal procedure's `MakeExportDecl` text. + /// + /// # Errors + /// + /// [`Error::MalformedDecl`] when the text is not a result type followed by + /// `@`/`!`-marked parameter types. + pub fn of_internal(decl: &[u8]) -> Result { + let malformed = || Error::MalformedDecl { + what: "internal proc declaration", + }; + let text = str::from_utf8(decl).map_err(|_| malformed())?; + let mut words = text.split(' ').filter(|word| !word.is_empty()); + let result = words.next().ok_or_else(malformed)?; + let result_type = match result { + "-1" => None, + number => Some(number.parse::().map_err(|_| malformed())?), + }; + let params = words + .map(|word| { + let (by_ref, number) = if let Some(number) = word.strip_prefix('!') { + (true, number) + } else if let Some(number) = word.strip_prefix('@') { + (false, number) + } else { + return Err(malformed()); + }; + let type_no = number.parse::().map_err(|_| malformed())?; + Ok(Param { + by_ref, + type_no: Some(type_no), + }) + }) + .collect::, _>>()?; + Ok(Self { + returns: result_type.is_some(), + result_type, + params, + }) + } + + /// Parses a `DeclToBits` string: a result byte, then one byte per + /// parameter. + /// + /// # Errors + /// + /// [`Error::MalformedDecl`] when it is empty or a byte is neither `0` nor + /// `1`. + pub fn of_bits(bits: &[u8]) -> Result { + let flag = |byte: &u8| match byte { + 0 => Ok(false), + 1 => Ok(true), + _ => Err(Error::MalformedDecl { + what: "external proc declaration bit", + }), + }; + let (result, params) = bits.split_first().ok_or(Error::MalformedDecl { + what: "external proc declaration", + })?; + Ok(Self { + returns: flag(result)?, + result_type: None, + params: params + .iter() + .map(|byte| { + Ok(Param { + by_ref: flag(byte)?, + type_no: None, + }) + }) + .collect::, Error>>()?, + }) + } + + /// The number of stack slots a call to this procedure consumes: every + /// parameter, and the result reference when there is one. + #[must_use] + pub fn slot_count(&self) -> usize { + self.params.len().saturating_add(usize::from(self.returns)) + } + + /// What stack slot `s+offset` holds inside this procedure's body. + #[must_use] + pub fn slot(&self, offset: i64) -> FrameSlot { + if offset == 0 { + return FrameSlot::ReturnAddress; + } + if offset > 0 { + return u32::try_from(offset).map_or(FrameSlot::Beyond, FrameSlot::Local); + } + // `s-1` first: the result when there is one, else the first parameter. + let below = offset.unsigned_abs(); + let param = if self.returns { + if below == 1 { + return FrameSlot::Result; + } + below.saturating_sub(2) + } else { + below.saturating_sub(1) + }; + match u32::try_from(param) { + Ok(index) if (index as usize) < self.params.len() => FrameSlot::Param(index), + _ => FrameSlot::Beyond, + } + } +} + +impl<'a> ExternalDecl<'a> { + /// Parses an external procedure's declaration: a `dll:` import or a host + /// function's `DeclToBits`. + /// + /// # Errors + /// + /// [`Error::MalformedDecl`] when a `dll:` import is missing a field, or the + /// bit string is malformed. + pub fn parse(decl: &'a [u8]) -> Result { + let Some(rest) = decl.strip_prefix(b"dll:") else { + return Ok(Self { + dll: None, + signature: Signature::of_bits(decl)?, + }); + }; + let malformed = || Error::MalformedDecl { + what: "dll import declaration", + }; + let mut fields = rest.splitn(3, |byte| *byte == 0); + let library = fields.next().ok_or_else(malformed)?; + let function = fields.next().ok_or_else(malformed)?; + let tail = fields.next().ok_or_else(malformed)?; + let [ + calling_convention, + delay_load, + altered_search_path, + bits @ .., + ] = tail + else { + return Err(malformed()); + }; + Ok(Self { + dll: Some(DllImport { + library, + function, + calling_convention: *calling_convention, + delay_load: *delay_load != 0, + altered_search_path: *altered_search_path != 0, + }), + signature: Signature::of_bits(bits)?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A function's result is `s-1` and its parameters follow below it; a + /// procedure's first parameter is `s-1`. Declarations from the compiled + /// `code.iss` fixture: `SCALE(Value, Factor: Integer): Integer` and + /// `BUMP(var Target: Integer; Step: Integer)`. + #[test] + fn a_declaration_places_the_result_and_parameters() { + let scale = Signature::of_internal(b"11 @11 @11").unwrap(); + assert!(scale.returns); + assert_eq!(scale.result_type, Some(11)); + assert_eq!(scale.slot_count(), 3); + assert_eq!(scale.slot(0), FrameSlot::ReturnAddress); + assert_eq!(scale.slot(-1), FrameSlot::Result); + assert_eq!(scale.slot(-2), FrameSlot::Param(0)); + assert_eq!(scale.slot(-3), FrameSlot::Param(1)); + assert_eq!(scale.slot(-4), FrameSlot::Beyond); + assert_eq!(scale.slot(2), FrameSlot::Local(2)); + + let bump = Signature::of_internal(b"-1 !11 @11").unwrap(); + assert!(!bump.returns); + assert_eq!( + bump.params, + [ + Param { + by_ref: true, + type_no: Some(11) + }, + Param { + by_ref: false, + type_no: Some(11) + }, + ] + ); + assert_eq!(bump.slot(-1), FrameSlot::Param(0)); + assert_eq!(bump.slot(-2), FrameSlot::Param(1)); + assert_eq!(bump.slot_count(), 2); + } + + /// A host function states only whether it returns and which parameters + /// are `var`; a DLL import adds the library, function and loading flags. + /// `MessageBeep@user32.dll stdcall` as the fixture compiled it. + #[test] + fn an_external_declaration_reads_both_forms() { + let host = ExternalDecl::parse(&[1, 0]).unwrap(); + assert_eq!(host.dll, None); + assert!(host.signature.returns); + assert_eq!(host.signature.params.len(), 1); + + let decl = b"dll:user32.dll\0MessageBeep\0\x03\x00\x00\x01\x00"; + let import = ExternalDecl::parse(decl).unwrap(); + assert_eq!( + import.dll, + Some(DllImport { + library: b"user32.dll", + function: b"MessageBeep", + calling_convention: 3, + delay_load: false, + altered_search_path: false, + }) + ); + assert!(import.signature.returns); + assert_eq!( + import.signature.params, + [Param { + by_ref: false, + type_no: None + }] + ); + } + + /// Malformed declarations are errors, never a guessed signature. + #[test] + fn a_malformed_declaration_is_refused() { + assert!(Signature::of_internal(b"").is_err()); + assert!(Signature::of_internal(b"11 #11").is_err()); + assert!(Signature::of_internal(b"x").is_err()); + assert!(Signature::of_bits(&[]).is_err()); + assert!(Signature::of_bits(&[1, 2]).is_err()); + assert!(ExternalDecl::parse(b"dll:user32.dll\0MessageBeep\0\x03").is_err()); + } +}