diff --git a/senbei-engine/src/windows/error.rs b/senbei-engine/src/windows/error.rs
index 60e423b..09bb0d5 100644
--- a/senbei-engine/src/windows/error.rs
+++ b/senbei-engine/src/windows/error.rs
@@ -91,6 +91,16 @@ impl std::fmt::Display for DescriptorTable {
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum UnpackError {
+ #[error(
+ "Companion missing? This loader expects a matching ._ payload. Keep it beside the input file (or add both files in the browser) and try again."
+ )]
+ CompanionMissing,
+
+ #[error(
+ "Companion doesn't match: the supplied ._ payload is too short or its header differs from the loader's. Use the companion from the same build; either file may be damaged."
+ )]
+ CompanionMismatch,
+
#[error("input too short (need at least {required} bytes, got {actual})")]
InputTooShort { actual: usize, required: usize },
diff --git a/senbei-io/src/windows/companion.rs b/senbei-io/src/windows/companion.rs
new file mode 100644
index 0000000..24e5fc2
--- /dev/null
+++ b/senbei-io/src/windows/companion.rs
@@ -0,0 +1,280 @@
+//! Conservative recognition of the loader's external-file declaration.
+
+use super::{HEADER_OFF, splice_companion};
+use senbei_engine::UnpackError;
+
+/// Recognize the observed PE32+ loader entry record, rather than guessing from
+/// file size, hollow sections, or an unpack failure (all occur in single-file
+/// builds too). Its last complemented immediate is a suffix appended to the
+/// module path before CreateFileW; zero means reopen the module itself.
+fn expects_companion(input: &[u8]) -> Option<()> {
+ senbei_engine::detect(input)?;
+ let pe = senbei_pe::parse(input).ok()?;
+ if !pe.is_pe32_plus
+ || u16::from_le_bytes(
+ input
+ .get(pe.pe_offset + 4..pe.pe_offset + 6)?
+ .try_into()
+ .ok()?,
+ ) != 0x8664
+ || pe.sections == 0
+ || pe.size_of_image == 0
+ || pe.sections_offset < pe.pe_offset + 24 + 0xf0
+ {
+ return None;
+ }
+ let headers_end = pe
+ .sections_offset
+ .checked_add(usize::from(pe.sections) * 40)?;
+ let header_size = u32::from_le_bytes(
+ input
+ .get(pe.pe_offset + 84..pe.pe_offset + 88)?
+ .try_into()
+ .ok()?,
+ ) as usize;
+ if header_size < headers_end || header_size > input.len() {
+ return None;
+ }
+ // A truncated section table/raw section is evidence of a broken stub, not
+ // sufficient evidence to diagnose a missing companion.
+ for section in senbei_pe::sections(input, pe).ok()? {
+ if section.raw_size != 0 {
+ let end = section.raw_offset.checked_add(section.raw_size)? as usize;
+ input.get(section.raw_offset as usize..end)?;
+ }
+ if section
+ .virtual_address
+ .checked_add(section.virtual_size.max(section.raw_size))?
+ > pe.size_of_image
+ {
+ return None;
+ }
+ }
+ let range = senbei_pe::rva_range(input, pe, pe.entry_rva, 63).ok()?;
+ let entry = &input[range];
+ let dword = |offset: usize| u32::from_le_bytes(entry[offset..offset + 4].try_into().unwrap());
+ // Four call/ret entry thunks must all reach the same, file-backed loader.
+ if entry[0] != 0xe8 || entry[5] != 0xc3 {
+ return None;
+ }
+ let target = i64::from(pe.entry_rva) + 5 + i64::from(dword(1) as i32);
+ let target = u32::try_from(target).ok()?;
+ senbei_pe::rva_range(input, pe, target, 1).ok()?;
+ for (offset, argument) in [(6, 0), (14, 1), (22, 2)] {
+ if entry[offset..offset + 3] != [0x6a, argument, 0xe8]
+ || entry[offset + 7] != 0xc3
+ || i64::from(pe.entry_rva) + offset as i64 + 7 + i64::from(dword(offset + 3) as i32)
+ != i64::from(target)
+ {
+ return None;
+ }
+ }
+ if entry[30..32] != [0x6a, 3]
+ || [32, 37, 42, 47, 52, 57]
+ .iter()
+ .any(|&offset| entry[offset] != 0x68)
+ || entry[62] != 0xc3
+ || dword(48).wrapping_add(pe.entry_rva).wrapping_add(1) != 0
+ || !dword(53) != HEADER_OFF as u32
+ || !dword(58) != u32::from_le_bytes(*b"._\0\0")
+ {
+ return None;
+ }
+ Some(())
+}
+
+pub(super) fn prepare_companion(
+ input: &[u8],
+ companion: Option<&[u8]>,
+) -> Result