Skip to content
Lesson 5.2Executable Maps, Instruction References, and Scans

File Offsets, RVAs, and Live Addresses

Connect positions in a PE file to relative virtual addresses and the addresses Windows gives its loaded modules.

Lesson 5.2
Read or listenPrint chapter or book
About 28 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 5.2Key ideas, reference and questions

A one-page summary of “File Offsets, RVAs, and Live Addresses” you can keep beside your notes. Plain Markdown.

Lesson 5.2 — File Offsets, RVAs, and Live Addresses: cheatsheet

  • Remember: File offset, RVA and live address are coordinates in different spaces. Convert between them with the section table and the module base.
  • Also useful: Think of a cookbook that says add a cup of water: the water is not printed, the cook supplies it. The loader supplies the zero bytes the same way.

Key terms

  • SizeOfHeaders: Header RVAs below SizeOfHeaders are a special case that commonly map directly to the same file offset.

Formulas and patterns

  • offset_in_section = RVA - V; file_offset = R + offset_in_section

Review

  • Q: A symbol has RVA 0x2500 and the module loaded at 0x7FF600000000. How is its live address found? A: Add the RVA to the live module base
  • Q: What is the difference between the preferred image base and the live module base? A: Preferred: where the file asks to load; live: where Windows put it this run
Download cheatsheet (.md)

The PE map in Lesson 5.1 told us that an instruction can have one position in the EXE file and another in a running process. Suppose your debugger recorded an instruction at 0x007CCD91 yesterday. To find it after restarting the game, you need to know which part of that number belonged to the module and which part Windows chose for that run.

We will use four names for the four different positions involved:

  • a preferred image base is where the file would like to be loaded;
  • a live module base is where Windows placed it in this process run;
  • an RVA is a distance from the beginning of the module;
  • a virtual address is the final live address used by the CPU.

An RVA is not a pointer stored in the game. It is arithmetic describing a location inside one loaded image. The PE file’s section table maps RVAs to sections such as code or data.

Do not confuse an RVA with a file offset. A file offset counts bytes in the EXE on disk. An RVA counts from the mapped image base after Windows has arranged sections according to virtual addresses and alignment. They can differ.

The complete vocabulary is worth making explicit:

Name Coordinate system Typical formula
File offset Bytes from the start of the file on disk section.raw_ptr + offset_in_section
RVA Bytes from the start of the mapped image section.virtual_address + offset_in_section
Preferred VA Address requested by the PE headers preferred_image_base + RVA
Live VA Address in this process now live_module_base + RVA

These are coordinates in different spaces. Adding a live module base to a file offset is the binary-analysis version of adding meters to seconds: the numbers fit in an integer, but the operation has no useful meaning.

To translate an RVA to a file offset, first find the PE section whose virtual range contains it. For a section with virtual start V, raw file start R, virtual size VS, and raw size RS:

offset_in_section = RVA - V
file_offset = R + offset_in_section

The mapping is valid only when the RVA lies in the section’s mapped range and offset_in_section < RS. A section can have more virtual bytes than raw bytes; the loader fills that tail with zeros, so no corresponding file byte exists. Conversely, alignment padding on disk is not necessarily meaningful mapped memory. Header RVAs below SizeOfHeaders are a special case that commonly map directly to the same file offset.

Example section:

VirtualAddress = 0x2000
VirtualSize = 0x0900
PointerToRawData = 0x0600
SizeOfRawData = 0x0800

RVA 0x2340 is 0x340 bytes into the section, so its file offset is 0x0600 + 0x0340 = 0x0940. RVA 0x2880 is mapped inside the virtual tail but is beyond the 0x800 raw bytes; it represents zero-filled memory rather than a byte stored in the file.

The same section as file offsets and as RVAsThe file stores 0x800 bytes starting at offset 0x0600. The image maps 0x900 bytes starting at RVA 0x2000. The last 0x100 are zeros.
  1. 1 The two bars, drawn to scale

    File section: 0x800 bytes at file offset 0x0600

    File
    offset 0x06000x0E00
    file bytes

    Mapped range: 0x900 bytes at RVA 0x2000

    Image
    RVA 0x20000x2900
    backed by the file
    0x100 zeros

    The mapped range is longer than the stored bytes by 0x100.

  2. 2 Offset 0x340 into the section

    File section: 0x800 bytes at file offset 0x0600

    File
    offset 0x06000x0E00
    offset 0x0940

    Mapped range: 0x900 bytes at RVA 0x2000

    Image
    RVA 0x20000x2900
    RVA 0x2340

    The same distance from the start gives file offset 0x0600 + 0x340 = 0x0940 and RVA 0x2000 + 0x340 = 0x2340.

  3. 3 RVA 0x2880 falls in the zero tail

    File section: 0x800 bytes at file offset 0x0600

    File
    offset 0x06000x0E00
    file bytes end here
    no file byte

    Mapped range: 0x900 bytes at RVA 0x2000

    Image
    RVA 0x20000x2900
    backed by the file
    RVA 0x2880 is in the zero tail

    That RVA is 0x880 into the range, past the 0x800 stored bytes, so it has no file byte at all.

The file stores 0x800 bytes starting at offset 0x0600. The image maps 0x900 bytes starting at RVA 0x2000. The last 0x100 are zeros.

Raw range on disk: 0x0600 to 0x0E00 (SizeOfRawData 0x800)
Virtual range: 0x2000 to 0x2900 (VirtualSize 0x900)
file_offset = 0x0600 + 0x340
0x880 is past 0x800:
zero-filled, no file byte
0x0E00
end
0x0940
+0x340
0x0600
section start
0x2900
end
0x2880
+0x880
0x2340
+0x340
0x2000
section start

Follow the 0x2340 branch through the section mapping. Subtraction finds a distance inside the section; addition then expresses it in the file’s coordinate system:

Watch it happenAn RVA, a distance into the section, and a file offset

Two bars drawn to the same scale: the loaded section covering RVAs 0x2000 to 0x2900 with a zero-filled tail after 0x2800, and the file’s raw data from 0x0600 to 0x0E00. RVA 0x2340 is 0x0340 into the section; adding that to 0x0600 gives file offset 0x0940. RVA 0x2880 is 0x0880 in, beyond the raw data, so it has no file byte.

Read the steps
  1. A section is stored in the file and also mapped into memory, and the two places are numbered differently. Its row says: RVA range [0x2000, 0x2900), 0x0800 bytes of raw data, stored from file offset 0x0600.
  2. Start with an RVA: 0x2340. It is a distance from the mapped image’s base, not a position in the file. It lies inside the section’s virtual range.
  3. Find how far into the section it is: 0x2340 − 0x2000 = 0x0340. That distance is the same in memory and in the file’s raw data.
  4. Check that the file has a byte there: SizeOfRawData is 0x0800, and 0x0340 is below it.
  5. Carry the same distance to the file: PointerToRawData 0x0600 + 0x0340 = 0x0940. That is where the byte lives on disk.
  6. A different RVA, 0x2880, fails the check. Its distance into the section is 0x0880, which is not below 0x0800: it falls in the zero-filled tail that exists only in memory.

The other illustrated RVA, 0x2880, fails the raw-byte check: its 0x880 section offset lies in the zero-filled virtual tail.

This simulation needs JavaScript. The lesson text around it works through the same numbers.

A rigorous parser checks every subtraction and addition, validates section ranges against the actual file length, and rejects overlapping or impossible metadata. PE headers describe a file; they do not deserve blind trust merely because Windows-shaped field names were decoded.

Resolving an RVA is useful only when the build identity and the live module range are part of the calculation:

Yes
No
Verify the exact module build
Use its known RVA
Query the live module base
Add base and RVA safely
Inside module range?
Use the live address
Stop with an error

ASLR, Address Space Layout Randomization, changes the live base, while a game update can change the RVA. Checking both facts keeps an old note from turning into a believable but wrong address.

Old notes often show an address such as 0x007CCD91. That may be the address from one run, not a permanent location.

Windows can load an executable at a different base address each run; that is what ASLR randomizes. The useful part that usually stays the same for an exact build is the distance from the module’s beginning: the Relative Virtual Address.

ASLR makes fixed absolute addresses less predictable. Windows can relocate an image when its PE metadata supports relocation. The module’s internal layout for that exact file usually stays the same, so base-plus-RVA reaches the same instruction even when the base changes.

live virtual address = live module base + RVA
RVA = observed address - observed module base

The useful invariant is algebraic: for one exact module build, an internal location’s displacement from the module base can remain stable even when the base changes. ASLR changes the first term, not the meaning of the second:

run A: base A + same RVA = address A
run B: base B + same RVA = address B

The RVA is stable only relative to that image layout. Recompiling or updating the game can move the instruction inside the module, so the build identity must travel with the RVA.

Small wrapper types can stop coordinate-space mistakes before runtime:

Optional: practise keeping the RVA inside its image before forming a live address.

#[derive(Clone, Copy, Debug)]
struct Rva(u32);
#[derive(Clone, Copy, Debug)]
struct LiveAddress(usize);
fn resolve_rva(base: LiveAddress, image_size: usize, rva: Rva) -> Option<LiveAddress> {
let offset = rva.0 as usize;
if offset >= image_size {
return None;
}
base.0.checked_add(offset).map(LiveAddress)
}

Both values are integers internally, but the type names preserve their units. The function also states two proof obligations: the RVA belongs to this image, and addition cannot overflow the address type.

For the 32-bit Wesnoth 1.14.9 course build, the original disassembly records the instruction at 0x007CCD91. If the module was loaded at 0x00400000 during that capture, subtracting the base gives the RVA; adding a different run’s base back to that same RVA gives that run’s own live address:

− base
+ base 0x00600000
(a later run)
capture run
observed VA 0x007CCD91
base 0x00400000
RVA
0x003CCD91
later run
live VA 0x009CCD91

Never assume the example base. Measure the module in the run you are actually debugging.

The shared Process wrapper uses CreateToolhelp32Snapshot, Module32FirstW, and Module32NextW from the windows crate. The chapter tool becomes small because those unsafe Windows boundaries already live in one reviewed module:

#[cfg(windows)]
fn main() -> anyhow::Result<()> {
use anyhow::Context;
use gha_windows_labs::Process;
let mut arguments = std::env::args().skip(1);
let process_name = arguments.next().unwrap_or_else(|| "wesnoth.exe".to_owned());
let module_name = arguments.next().unwrap_or_else(|| process_name.clone());
let requested_rva = arguments
.next()
.map(|text| usize::from_str_radix(text.trim_start_matches("0x"), 16))
.transpose()
.context("RVA must be hexadecimal, such as 3CCD91")?;
let process = Process::open_by_name(&process_name, false)?;
let (base, size) = process.module(&module_name)?;
let end = base.checked_add(size).context("module range overflowed")?;
println!("{} (PID {})", process.name(), process.id());
println!(" live base: {base:#010x}");
println!(" size: {size:#010x}");
println!(" end: {end:#010x}");
if let Some(rva) = requested_rva {
anyhow::ensure!(rva < size, "RVA {rva:#x} is outside the module");
let address = base.checked_add(rva).context("base + RVA overflowed")?;
println!(" {base:#010x} + {rva:#010x} = {address:#010x}");
}
Ok(())
}

The two guards matter. An RVA outside the module is probably for the wrong version, and an overflowing addition is never a valid address.

ToolHelp returns information about modules in the chosen process. The snapshot is still a moment-in-time view: a DLL can unload after enumeration. For the course EXE this is stable during the short calculation, but reusable tooling should handle a later read failure normally.

Windows VM

Start a local Wesnoth match, then run:

Terminal window
.\target\i686-pc-windows-msvc\release\module_inspector.exe `
wesnoth.exe wesnoth.exe 3CCD91

Open the printed live address in x64dbg. The bytes and disassembly must match the lesson before you use the address.

Repeat the process after restarting Wesnoth. If the base changes but base + RVA still reaches the expected instruction, you have separated stable location information from one-run information.

The buildable tool is module_inspector.rs, and its ToolHelp implementation is in process.rs.

module_inspector.rs

windows-labs/src/bin/ · Rust, 36 lines

Show the codeHide the code
#[cfg(windows)]
fn main() -> anyhow::Result<()> {
    use anyhow::Context;
    use gha_windows_labs::Process;

    let mut arguments = std::env::args().skip(1);
    let process_name = arguments.next().unwrap_or_else(|| "wesnoth.exe".to_owned());
    let module_name = arguments.next().unwrap_or_else(|| process_name.clone());
    let requested_rva = arguments
        .next()
        .map(|text| usize::from_str_radix(text.trim_start_matches("0x"), 16))
        .transpose()
        .context("RVA must be hexadecimal, such as 7CCD91")?;

    let process = Process::open_by_name(&process_name, false)?;
    let (base, size) = process.module(&module_name)?;
    let end = base.checked_add(size).context("module range overflowed")?;

    println!("{} (PID {})", process.name(), process.id());
    println!("  module: {module_name}");
    println!("  live base: {base:#010x}");
    println!("  size:      {size:#010x}");
    println!("  end:       {end:#010x}");

    if let Some(rva) = requested_rva {
        anyhow::ensure!(rva < size, "RVA {rva:#x} is outside the module");
        let address = base.checked_add(rva).context("base + RVA overflowed")?;
        println!("  {base:#010x} + {rva:#010x} = {address:#010x}");
    }
    Ok(())
}

#[cfg(not(windows))]
fn main() {
    eprintln!("This live module inspector uses ToolHelp and must run on Windows.");
}

process.rs

windows-labs/src/windows_impl/ · Rust, 350 lines

Show the codeHide the code
use std::{ffi::c_void, mem::size_of};

use anyhow::{Context, Result};
use windows::Win32::{
    Foundation::ERROR_NO_MORE_FILES,
    System::{
        Diagnostics::{
            Debug::{FlushInstructionCache, ReadProcessMemory, WriteProcessMemory},
            ToolHelp::{
                CreateToolhelp32Snapshot, MODULEENTRY32W, Module32FirstW, Module32NextW,
                PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPMODULE,
                TH32CS_SNAPMODULE32, TH32CS_SNAPPROCESS,
            },
        },
        Memory::{
            MEM_COMMIT, MEMORY_BASIC_INFORMATION, PAGE_EXECUTE, PAGE_EXECUTE_READ,
            PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS,
            PAGE_PROTECTION_FLAGS, PAGE_READONLY, PAGE_READWRITE, PAGE_WRITECOPY, VirtualProtectEx,
            VirtualQueryEx,
        },
        SystemInformation::{
            IMAGE_FILE_MACHINE, IMAGE_FILE_MACHINE_I386, IMAGE_FILE_MACHINE_UNKNOWN,
        },
        Threading::{
            IsWow64Process2, OpenProcess, PROCESS_ACCESS_RIGHTS, PROCESS_QUERY_INFORMATION,
            PROCESS_VM_OPERATION, PROCESS_VM_READ, PROCESS_VM_WRITE,
        },
    },
};

use super::OwnedHandle;

#[derive(Clone, Debug)]
pub struct ProcessEntry {
    pub id: u32,
    pub name: String,
}

#[derive(Clone, Copy, Debug)]
pub struct MemoryRegion {
    pub base: usize,
    pub size: usize,
    pub readable: bool,
    pub writable: bool,
    pub executable: bool,
}

#[derive(Debug)]
pub struct Process {
    handle: OwnedHandle,
    id: u32,
    name: String,
}

fn wide_text(buffer: &[u16]) -> String {
    let end = buffer
        .iter()
        .position(|unit| *unit == 0)
        .unwrap_or(buffer.len());
    String::from_utf16_lossy(&buffer[..end])
}

fn no_more_files(error: &windows::core::Error) -> bool {
    error.code() == ERROR_NO_MORE_FILES.to_hresult()
}

impl Process {
    /// Lists every running process with ToolHelp.
    pub fn list() -> Result<Vec<ProcessEntry>> {
        // SAFETY: no borrowed pointers are passed; the returned snapshot is owned below.
        let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }?;
        let snapshot = OwnedHandle::from_raw(snapshot)?;

        let mut raw = PROCESSENTRY32W {
            dwSize: size_of::<PROCESSENTRY32W>() as u32,
            ..Default::default()
        };

        // SAFETY: `raw` is correctly sized and writable for the duration of the call.
        unsafe { Process32FirstW(snapshot.raw(), &mut raw) }?;

        let mut processes = Vec::new();
        loop {
            processes.push(ProcessEntry {
                id: raw.th32ProcessID,
                name: wide_text(&raw.szExeFile),
            });

            // SAFETY: `raw` remains a valid PROCESSENTRY32W output buffer.
            match unsafe { Process32NextW(snapshot.raw(), &mut raw) } {
                Ok(()) => {}
                Err(error) if no_more_files(&error) => break,
                Err(error) => return Err(error.into()),
            }
        }
        Ok(processes)
    }

    pub fn find(name: &str) -> Result<ProcessEntry> {
        Self::list()?
            .into_iter()
            .find(|entry| entry.name.eq_ignore_ascii_case(name))
            .with_context(|| format!("process {name:?} is not running"))
    }

    pub fn open_by_name(name: &str, allow_write: bool) -> Result<Self> {
        let entry = Self::find(name)?;
        Self::open(entry, allow_write)
    }

    pub fn open(entry: ProcessEntry, allow_write: bool) -> Result<Self> {
        let mut access: PROCESS_ACCESS_RIGHTS = PROCESS_QUERY_INFORMATION | PROCESS_VM_READ;
        if allow_write {
            access |= PROCESS_VM_OPERATION | PROCESS_VM_WRITE;
        }

        Self::open_with_access(entry, access)
    }

    pub fn open_with_access(entry: ProcessEntry, access: PROCESS_ACCESS_RIGHTS) -> Result<Self> {
        // SAFETY: the PID is a value; inheritance is disabled; `OwnedHandle` owns the result.
        let handle = unsafe { OpenProcess(access, false, entry.id) }?;
        Ok(Self {
            handle: OwnedHandle::from_raw(handle)?,
            id: entry.id,
            name: entry.name,
        })
    }

    pub fn is_32_bit(&self) -> Result<bool> {
        let mut process_machine = IMAGE_FILE_MACHINE::default();
        let mut native_machine = IMAGE_FILE_MACHINE::default();
        // SAFETY: both output pointers remain valid for the call.
        unsafe {
            IsWow64Process2(
                self.handle.raw(),
                &mut process_machine,
                Some(&mut native_machine),
            )?;
        }
        Ok(process_machine == IMAGE_FILE_MACHINE_I386
            || (process_machine == IMAGE_FILE_MACHINE_UNKNOWN
                && native_machine == IMAGE_FILE_MACHINE_I386))
    }

    pub fn id(&self) -> u32 {
        self.id
    }

    pub fn name(&self) -> &str {
        &self.name
    }

    pub fn raw_handle(&self) -> windows::Win32::Foundation::HANDLE {
        self.handle.raw()
    }

    pub fn read_exact(&self, address: usize, output: &mut [u8]) -> Result<()> {
        let mut bytes_read = 0_usize;
        // SAFETY: `output` is writable for its length. Windows validates the remote range.
        unsafe {
            ReadProcessMemory(
                self.handle.raw(),
                address as *const c_void,
                output.as_mut_ptr().cast(),
                output.len(),
                Some(&mut bytes_read),
            )?;
        }
        anyhow::ensure!(bytes_read == output.len(), "short read at {address:#x}");
        Ok(())
    }

    pub fn read_bytes(&self, address: usize, count: usize) -> Result<Vec<u8>> {
        let mut bytes = vec![0_u8; count];
        self.read_exact(address, &mut bytes)?;
        Ok(bytes)
    }

    pub fn read_u32(&self, address: usize) -> Result<u32> {
        let bytes: [u8; 4] = self
            .read_bytes(address, 4)?
            .try_into()
            .expect("the read length is exactly four");
        Ok(u32::from_le_bytes(bytes))
    }

    pub fn read_f32(&self, address: usize) -> Result<f32> {
        Ok(f32::from_bits(self.read_u32(address)?))
    }

    pub fn write_exact(&self, address: usize, bytes: &[u8]) -> Result<()> {
        let mut bytes_written = 0_usize;
        // SAFETY: `bytes` is readable for its length. Windows validates the remote range.
        unsafe {
            WriteProcessMemory(
                self.handle.raw(),
                address as *const c_void,
                bytes.as_ptr().cast(),
                bytes.len(),
                Some(&mut bytes_written),
            )?;
        }
        anyhow::ensure!(bytes_written == bytes.len(), "short write at {address:#x}");
        Ok(())
    }

    pub fn write_u32(&self, address: usize, value: u32) -> Result<()> {
        self.write_exact(address, &value.to_le_bytes())
    }

    /// Writes machine code, flushes the CPU instruction cache, and restores protection.
    pub fn write_code(&self, address: usize, bytes: &[u8]) -> Result<()> {
        let mut old = PAGE_PROTECTION_FLAGS::default();
        // SAFETY: Windows validates the target range and writes `old` before returning.
        unsafe {
            VirtualProtectEx(
                self.handle.raw(),
                address as *const c_void,
                bytes.len(),
                PAGE_EXECUTE_READWRITE,
                &mut old,
            )?;
        }

        let write_result = self.write_exact(address, bytes);
        if write_result.is_ok() {
            // SAFETY: the process handle is live and the written range is the one above.
            unsafe {
                FlushInstructionCache(
                    self.handle.raw(),
                    Some(address as *const c_void),
                    bytes.len(),
                )?;
            }
        }

        let mut ignored = PAGE_PROTECTION_FLAGS::default();
        // SAFETY: this restores the protection returned by the first call.
        let restore_result = unsafe {
            VirtualProtectEx(
                self.handle.raw(),
                address as *const c_void,
                bytes.len(),
                old,
                &mut ignored,
            )
        };

        write_result?;
        restore_result?;
        Ok(())
    }

    pub fn module(&self, wanted_name: &str) -> Result<(usize, usize)> {
        // Include SNAPMODULE32 so a 64-bit tool can list a 32-bit game module.
        // SAFETY: the PID is valid and the returned snapshot is owned below.
        let snapshot =
            unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, self.id) }?;
        let snapshot = OwnedHandle::from_raw(snapshot)?;
        let mut raw = MODULEENTRY32W {
            dwSize: size_of::<MODULEENTRY32W>() as u32,
            ..Default::default()
        };

        // SAFETY: `raw` is correctly sized and writable.
        unsafe { Module32FirstW(snapshot.raw(), &mut raw) }?;
        loop {
            if wide_text(&raw.szModule).eq_ignore_ascii_case(wanted_name) {
                return Ok((raw.modBaseAddr as usize, raw.modBaseSize as usize));
            }
            // SAFETY: `raw` remains a valid output buffer.
            match unsafe { Module32NextW(snapshot.raw(), &mut raw) } {
                Ok(()) => {}
                Err(error) if no_more_files(&error) => break,
                Err(error) => return Err(error.into()),
            }
        }
        anyhow::bail!("module {wanted_name:?} was not found in {}", self.name)
    }

    pub fn regions(&self, start: usize, end: usize) -> Result<Vec<MemoryRegion>> {
        let mut current = start;
        let mut regions = Vec::new();

        while current < end {
            let mut raw = MEMORY_BASIC_INFORMATION::default();
            // SAFETY: `raw` is a valid output structure; Windows validates the address.
            let returned = unsafe {
                VirtualQueryEx(
                    self.handle.raw(),
                    Some(current as *const c_void),
                    &mut raw,
                    size_of::<MEMORY_BASIC_INFORMATION>(),
                )
            };
            if returned == 0 {
                break;
            }

            let protection = raw.Protect;
            let blocked = protection.contains(PAGE_GUARD) || protection.contains(PAGE_NOACCESS);
            let readable = !blocked
                && raw.State == MEM_COMMIT
                && [
                    PAGE_READONLY,
                    PAGE_READWRITE,
                    PAGE_WRITECOPY,
                    PAGE_EXECUTE_READ,
                    PAGE_EXECUTE_READWRITE,
                    PAGE_EXECUTE_WRITECOPY,
                ]
                .iter()
                .any(|flag| protection.contains(*flag));
            let writable = readable
                && [
                    PAGE_READWRITE,
                    PAGE_WRITECOPY,
                    PAGE_EXECUTE_READWRITE,
                    PAGE_EXECUTE_WRITECOPY,
                ]
                .iter()
                .any(|flag| protection.contains(*flag));
            let executable = !blocked
                && [
                    PAGE_EXECUTE,
                    PAGE_EXECUTE_READ,
                    PAGE_EXECUTE_READWRITE,
                    PAGE_EXECUTE_WRITECOPY,
                ]
                .iter()
                .any(|flag| protection.contains(*flag));

            let base = raw.BaseAddress as usize;
            let size = raw.RegionSize;
            regions.push(MemoryRegion {
                base,
                size,
                readable,
                writable,
                executable,
            });

            let next = base.checked_add(size).context("memory map overflowed")?;
            anyhow::ensure!(next > current, "memory map did not advance");
            current = next;
        }
        Ok(regions)
    }
}

An RVA can move when the game is rebuilt. Keep all three checks:

  1. exact supported game version;
  2. live module base plus the version’s RVA;
  3. exact original bytes or a unique pattern at the final address.

That is why the code-cave installers reject unknown bytes instead of hoping the address is close enough.

References: Microsoft PE format and GetModuleHandleW.

Knowledge check

Relocate the symbol

A symbol has RVA 0x2500 and the module loaded at 0x7FF600000000. How is its live address found?

Finished Lesson 5.2?

Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.

Chapter 5: 11 lessons

This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.