Skip to content

Instantly share code, notes, and snippets.

@Kreijstal
Last active May 26, 2026 04:11
Show Gist options
  • Select an option

  • Save Kreijstal/57858e7c8d31fadf97bf67d51f94173c to your computer and use it in GitHub Desktop.

Select an option

Save Kreijstal/57858e7c8d31fadf97bf67d51f94173c to your computer and use it in GitHub Desktop.
ReactOS NTFS Debug — GDB Real/Long Mode Guide, Pilotty, Drive Installer, Memory Layout
# ReactOS GDB debug helper for QEMU
# Usage: gdb -x .gdbinit
# Then: ros-connect (connects to :1234, loads symbols)
# Or: ros-connect :5678 (custom port)
#
# Non-aggressive: does NOT auto-connect or auto-load.
# All commands are registered immediately and work once connected.
set architecture i386:x86-64
set disassembly-flavor intel
set pagination off
set confirm off
python
import gdb
import struct
import os
import re as _re
KSEG0_BASE = 0xFFFFF80000000000
def _detect_build_dir():
env = os.environ.get('ROS_BUILD_DIR')
if env and os.path.isdir(env):
return env
cwd = os.getcwd()
# Try cwd itself first (gdb may already be inside the build dir)
if os.path.isfile(os.path.join(cwd, 'ntoskrnl', 'ntoskrnl.exe')):
return cwd
for cand in ('build_nt62', 'build', 'build_nt61', 'build_nt6', 'build_nt52'):
p = os.path.join(cwd, cand)
if os.path.isfile(os.path.join(p, 'ntoskrnl', 'ntoskrnl.exe')):
return p
# Last resort: walk up looking for build_nt62/ntoskrnl
parent = os.path.dirname(cwd)
while parent and parent != '/':
for cand in ('build_nt62', 'build', 'build_nt61', 'build_nt6'):
p = os.path.join(parent, cand)
if os.path.isdir(os.path.join(p, 'ntoskrnl')):
return p
parent = os.path.dirname(parent)
return os.path.join(cwd, 'build')
BUILD_DIR = _detect_build_dir()
# ============================================================
# Memory read helpers
# ============================================================
def read_mem(addr, size):
try:
inferior = gdb.selected_inferior()
return bytes(inferior.read_memory(addr, size))
except:
return None
def read_u8(addr):
data = read_mem(addr, 1)
return data[0] if data else None
def read_u16(addr):
data = read_mem(addr, 2)
return struct.unpack('<H', data)[0] if data else None
def read_i16(addr):
data = read_mem(addr, 2)
return struct.unpack('<h', data)[0] if data else None
def read_u32(addr):
data = read_mem(addr, 4)
return struct.unpack('<I', data)[0] if data else None
def read_u64(addr):
data = read_mem(addr, 8)
return struct.unpack('<Q', data)[0] if data else None
def read_unicode_string(addr):
length = read_u16(addr)
buf_ptr = read_u64(addr + 8)
if length is None or buf_ptr is None or length == 0 or length > 512:
return None
data = read_mem(buf_ptr, length)
if data is None:
return None
try:
return data.decode('utf-16-le')
except:
return None
def fmt_ptr(value):
return f"0x{value:016x}" if value is not None else "?"
def read_ascii_string(addr, max_len=16):
data = read_mem(addr, max_len)
if not data:
return None
data = data.split(b"\x00", 1)[0]
try:
return data.decode("ascii", errors="replace")
except:
return None
# ============================================================
# PE helpers
# ============================================================
def find_pe_base_from_rip():
try:
rip = int(gdb.parse_and_eval("$rip"))
except:
return None
if rip < KSEG0_BASE:
return None
page = rip & ~0xFFF
for _ in range(4096):
sig = read_u16(page)
if sig == 0x5A4D:
pe_off = read_u32(page + 0x3C)
if pe_off and pe_off < 0x1000:
pe_sig = read_u32(page + pe_off)
if pe_sig == 0x00004550:
return page
page -= 0x1000
return None
def get_pe_all_sections(base):
pe_off = read_u32(base + 0x3C)
if not pe_off:
return []
num_sections = read_u16(base + pe_off + 6)
size_opt = read_u16(base + pe_off + 20)
if num_sections is None or size_opt is None:
return []
sec_start = base + pe_off + 24 + size_opt
sections = []
for i in range(num_sections):
sec = sec_start + i * 40
sec_name = read_mem(sec, 8)
if sec_name is None:
continue
sec_name = sec_name.rstrip(b'\x00').decode('ascii', errors='replace')
rva = read_u32(sec + 12)
if rva:
sections.append((sec_name, rva))
return sections
def load_pe_symbols(base, name, filepath):
sections = get_pe_all_sections(base)
text_rva = None
extra_sections = []
for sname, rva in sections:
if sname == '.text':
text_rva = rva
elif not sname.startswith('.debug'):
extra_sections.append((sname, rva))
if text_rva is None:
text_rva = 0x1000
text_addr = base + text_rva
section_args = ' '.join(f'-s {sname} 0x{base + rva:x}' for sname, rva in extra_sections)
cmd = f'add-symbol-file {filepath} 0x{text_addr:x} {section_args}'
try:
gdb.execute(cmd, to_string=True)
print(f" Loaded {name} @ 0x{base:x} (.text=0x{text_addr:x})")
return True
except Exception as e:
print(f" Failed to load {name}: {e}")
return False
def addr_to_sym(addr):
try:
sym = gdb.execute(f"info symbol 0x{addr:x}", to_string=True).strip()
if "No symbol" not in sym:
parts = sym.split(" in section ")
return parts[0]
except:
pass
return None
# ============================================================
# CPU mode detection and context helpers
# ============================================================
def detect_cpu_context():
"""Detect current execution context.
Returns dict with 'mode' ('kernel', 'user64', 'compat32', 'real', 'unknown'),
'cs', 'rip', 'rsp', and 'can_read_kernel' flag."""
try:
cs = int(gdb.parse_and_eval("$cs"))
rip = int(gdb.parse_and_eval("$rip"))
rsp = int(gdb.parse_and_eval("$rsp"))
except:
return {'mode': 'unknown', 'cs': 0, 'rip': 0, 'rsp': 0, 'can_read_kernel': False}
if cs == 0x10:
return {'mode': 'kernel', 'cs': cs, 'rip': rip, 'rsp': rsp, 'can_read_kernel': True}
elif cs == 0x33:
return {'mode': 'user64', 'cs': cs, 'rip': rip, 'rsp': rsp, 'can_read_kernel': True}
elif cs == 0x23:
return {'mode': 'compat32', 'cs': cs, 'rip': rip, 'rsp': rsp, 'can_read_kernel': True}
else:
# Check if real mode
try:
cr0 = int(gdb.parse_and_eval("$cr0"))
if not (cr0 & 1):
return {'mode': 'real', 'cs': cs, 'rip': rip, 'rsp': rsp, 'can_read_kernel': False}
except:
pass
return {'mode': 'unknown', 'cs': cs, 'rip': rip, 'rsp': rsp, 'can_read_kernel': False}
class ReactosWhere(gdb.Command):
"""Show current CPU context: kernel, user64, compat32, or real mode.
If in user mode, shows how to access kernel structures.
Usage: ros-where"""
def __init__(self):
super().__init__("ros-where", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
ctx = detect_cpu_context()
mode_desc = {
'kernel': 'Kernel mode (ring 0)',
'user64': 'User mode 64-bit',
'compat32': 'User mode 32-bit compat (WoW64!)',
'real': 'Real mode (bootloader)',
'unknown': 'Unknown',
}
print(f"Mode: {mode_desc.get(ctx['mode'], ctx['mode'])}")
print(f"CS: 0x{ctx['cs']:x}")
print(f"RIP: 0x{ctx['rip']:x}")
print(f"RSP: 0x{ctx['rsp']:x}")
if ctx['mode'] == 'compat32':
print(f"\n ** 32-bit compat mode detected (WoW64 code running)")
print(f" ** EIP=0x{ctx['rip'] & 0xFFFFFFFF:x} ESP=0x{ctx['rsp'] & 0xFFFFFFFF:x}")
# Try to read GS base (KPCR) — works even in user mode via QEMU gdbstub
try:
gsbase = int(gdb.parse_and_eval("$gs_base"))
print(f" ** GS base (KPCR) = 0x{gsbase:x}")
except:
pass
print(f" ** Kernel structures are still accessible via QEMU gdbstub")
print(f" ** Use ros-load-symbols, ros-lsmod etc. normally")
elif ctx['mode'] == 'user64':
print(f"\n ** 64-bit user mode")
# Try to identify the module
sym = addr_to_sym(ctx['rip'])
if sym:
print(f" ** RIP is in: {sym}")
print(f" ** Kernel structures are accessible via QEMU gdbstub")
elif ctx['mode'] == 'kernel':
sym = addr_to_sym(ctx['rip'])
if sym:
print(f" ** RIP is in: {sym}")
# Check if we can read kernel memory (QEMU gdbstub gives full access)
if ctx['can_read_kernel']:
test = read_u16(KSEG0_BASE + 0x400000)
if test == 0x5A4D:
print(f"\n Kernel memory accessible (ntoskrnl MZ header verified)")
else:
print(f"\n WARNING: Cannot read ntoskrnl at expected location")
ReactosWhere()
# ============================================================
# KTHREAD / KTRAP_FRAME offsets (ReactOS amd64)
# ============================================================
KTHREAD_INITIAL_STACK = 0x28
KTHREAD_KERNEL_STACK = 0x58
KTHREAD_TRAP_FRAME = 0x90
KTHREAD_APC_STATE = 0x98
KTHREAD_WAIT_BLOCK_LIST = 0x0D0
KTHREAD_STATE = 0x184
KTHREAD_KERNEL_APC_DISABLE = 0x1E4
KTHREAD_SPECIAL_APC_DISABLE = 0x1E6
KTHREAD_WAIT_REASON = 0x283
EPROCESS_UNIQUE_PROCESS_ID = 0x0F8
EPROCESS_ACTIVE_PROCESS_LINKS = 0x100
EPROCESS_IMAGE_FILE_NAME = 0x260
EPROCESS_THREAD_LIST_HEAD = 0x288
ETHREAD_CID = 0x398
ETHREAD_THREAD_LIST_ENTRY = 0x408
KAPC_STATE_KERNEL_LIST = 0x00
KAPC_STATE_USER_LIST = 0x10
KAPC_STATE_PROCESS = 0x20
KAPC_STATE_IN_PROGRESS = 0x28
KAPC_STATE_KAPC_PENDING = 0x29
KAPC_STATE_UAPC_PENDING = 0x2A
KAPC_APC_LIST_ENTRY = 0x10
KAPC_KERNEL_ROUTINE = 0x20
KAPC_NORMAL_ROUTINE = 0x30
KWAIT_BLOCK_THREAD = 0x18
KWAIT_BLOCK_OBJECT = 0x20
TF_RAX = 0x030
TF_RCX = 0x038
TF_RDX = 0x040
TF_R8 = 0x048
TF_R9 = 0x050
TF_R10 = 0x058
TF_R11 = 0x060
TF_GSBASE = 0x068
TF_FAULT_ADDR = 0x0D0
TF_DR0 = 0x0D8
TF_SEG_DS = 0x130
TF_SEG_ES = 0x132
TF_SEG_FS = 0x134
TF_SEG_GS = 0x136
TF_TRAP_LINK = 0x138
TF_RBX = 0x140
TF_RDI = 0x148
TF_RSI = 0x150
TF_RBP = 0x158
TF_ERROR_CODE = 0x160
TF_RIP = 0x168
TF_SEG_CS = 0x170
TF_EFLAGS = 0x178
TF_RSP = 0x180
TF_SEG_SS = 0x188
TF_SIZE = 0x190
TF_PREV_IRQL = 0x029
TF_PREV_MODE = 0x028
POOL_BLOCK_SIZE = 16
# ============================================================
# ros-connect: connect to QEMU and optionally load symbols
# ============================================================
class ReactosConnect(gdb.Command):
"""Connect to QEMU gdbstub and load ntoskrnl symbols.
Usage: ros-connect [host:port]
Default: ros-connect :1234"""
def __init__(self):
super().__init__("ros-connect", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
target = arg.strip() if arg.strip() else ":1234"
try:
gdb.execute(f"target remote {target}")
except Exception as e:
print(f"Connection failed: {e}")
return
# Detect mode
try:
cr0 = int(gdb.parse_and_eval("$cr0"))
efer = int(gdb.parse_and_eval("$efer"))
pe = cr0 & 1
lma = efer & 0x400
if not pe:
print("CPU mode: real (16-bit)")
return
elif lma:
print("CPU mode: long (64-bit)")
else:
print("CPU mode: protected (32-bit)")
return
except:
print("Cannot detect CPU mode")
# Auto-load ntoskrnl
gdb.execute("ros-load-symbols")
ReactosConnect()
# ============================================================
# ros-load-symbols
# ============================================================
def _pe_export_name(base):
"""Read the PE export DLL name at the given mapped base, or None."""
try:
pe_off = read_u32(base + 0x3C)
if not pe_off or pe_off >= 0x1000:
return None
if read_u32(base + pe_off) != 0x00004550:
return None
# ImageBase=24 bytes from PE+24, Magic 0x20b for PE32+; assume PE32+ for amd64.
# Data directory 0 (export) is at OptionalHeader+112 on PE32+.
opt_off = base + pe_off + 24
exp_rva = read_u32(opt_off + 112)
if not exp_rva:
return None
name_rva = read_u32(base + exp_rva + 0xc)
if not name_rva:
return None
# Read up to 64 bytes of the DLL name.
infer = gdb.selected_inferior()
raw = bytes(infer.read_memory(base + name_rva, 64))
nul = raw.find(b'\x00')
if nul >= 0:
raw = raw[:nul]
return raw.decode('ascii', errors='replace').lower()
except Exception:
return None
def _find_kernel_pe_base():
"""Scan the kernel mapping region for an ntoskrnl.exe/ntkrnlmp.exe PE.
FreeLdr maps the kernel image somewhere in [KSEG0_BASE, KSEG0_BASE+~4MB);
the actual address depends on the PE size and previous allocations
(e.g. it differs between ntoskrnl.exe and ntkrnlmp.exe). Try the canonical
0x400000 base first, then scan."""
cands = [KSEG0_BASE + 0x400000]
# Walk down/up in 4 KiB pages, ntkrnlmp is typically below 0x400000.
for off in range(0x100000, 0x800000, 0x1000):
cands.append(KSEG0_BASE + off)
seen = set()
for base in cands:
if base in seen:
continue
seen.add(base)
try:
if read_u16(base) != 0x5A4D:
continue
except Exception:
continue
name = _pe_export_name(base)
if name and (name == 'ntoskrnl.exe' or name == 'ntkrnlmp.exe'):
return base, name
return None, None
class ReactosLoadSymbols(gdb.Command):
"""Load ReactOS kernel symbols. Finds the kernel base by scanning."""
def __init__(self):
super().__init__("ros-load-symbols", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
ntos_path = os.path.join(BUILD_DIR, 'ntoskrnl', 'ntoskrnl.exe')
ntmp_path = os.path.join(BUILD_DIR, 'ntoskrnl', 'ntkrnlmp', 'ntkrnlmp.exe')
base, name = _find_kernel_pe_base()
if base is None:
# Last-ditch fallback: probe near the current RIP.
base = find_pe_base_from_rip()
if base is not None:
name = _pe_export_name(base) or 'ntoskrnl.exe'
if base is None:
print(" Could not find ntoskrnl/ntkrnlmp")
return
if name == 'ntkrnlmp.exe' and os.path.exists(ntmp_path):
path = ntmp_path
label = 'ntkrnlmp.exe'
elif os.path.exists(ntos_path):
path = ntos_path
label = 'ntoskrnl.exe'
else:
print(f"ERROR: neither {ntos_path} nor {ntmp_path} present")
return
print(f" {label} @ 0x{base:x}")
load_pe_symbols(base, label, path)
ReactosLoadSymbols()
class ReactosLoadAt(gdb.Command):
"""Load ntoskrnl symbols at a given base. Usage: ros-load-at <addr>"""
def __init__(self):
super().__init__("ros-load-at", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-load-at <base-address>")
return
base = int(arg.strip(), 0)
ntos_path = os.path.join(BUILD_DIR, 'ntoskrnl', 'ntoskrnl.exe')
if not os.path.exists(ntos_path):
print(f"ERROR: {ntos_path} not found")
return
load_pe_symbols(base, "ntoskrnl.exe", ntos_path)
ReactosLoadAt()
class ReactosLoadModule(gdb.Command):
"""Load symbols for a module. Usage: ros-load-module <base> <build-path>"""
def __init__(self):
super().__init__("ros-load-module", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
parts = arg.strip().split(None, 1)
if len(parts) < 2:
print("Usage: ros-load-module <base-addr> <build/relative/path>")
return
base = int(parts[0], 0)
relpath = parts[1]
filepath = os.path.join(BUILD_DIR, relpath)
if not os.path.exists(filepath):
print(f"ERROR: {filepath} not found")
return
load_pe_symbols(base, os.path.basename(relpath), filepath)
ReactosLoadModule()
# ============================================================
# ros-lsmod
# ============================================================
MODULE_PATHS = {
'ntoskrnl.exe': 'ntoskrnl/ntoskrnl.exe',
'hal.dll': 'hal/halx86/hal.dll',
'kdcom.dll': 'drivers/base/kdcom/kdcom.dll',
'bootvid.dll': 'drivers/base/bootvid/bootvid.dll',
'ntfs.sys': 'drivers/filesystems/ntfs/ntfs.sys',
'win32k.sys': 'win32ss/win32k.sys',
'scsiport.sys': 'drivers/storage/port/scsiport/scsiport.sys',
}
class ReactosModuleList(gdb.Command):
"""Walk PsLoadedModuleList. Usage: ros-lsmod [--load]"""
def __init__(self):
super().__init__("ros-lsmod", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
do_load = '--load' in arg
try:
list_head = int(gdb.parse_and_eval("(unsigned long long)&PsLoadedModuleList"))
except:
print("PsLoadedModuleList not found. Load ntoskrnl symbols first.")
return
flink = read_u64(list_head)
if flink is None:
print("Cannot read PsLoadedModuleList")
return
print(f"{'Base':>20s} {'Size':>10s} Name")
print(f"{'----':>20s} {'----':>10s} ----")
entry_addr = flink
count = 0
while entry_addr != list_head and count < 256:
dll_base = read_u64(entry_addr + 0x30)
size = read_u32(entry_addr + 0x40)
name = read_unicode_string(entry_addr + 0x58)
if dll_base is None:
break
name_str = name if name else "<unknown>"
size_str = f"0x{size:x}" if size else "?"
print(f" 0x{dll_base:016x} {size_str:>10s} {name_str}")
if do_load and name:
lname = name.lower()
if lname in MODULE_PATHS:
fpath = os.path.join(BUILD_DIR, MODULE_PATHS[lname])
if os.path.exists(fpath):
load_pe_symbols(dll_base, name, fpath)
entry_addr = read_u64(entry_addr)
if entry_addr is None:
break
count += 1
print(f"\n{count} modules loaded")
ReactosModuleList()
# ============================================================
# ros-addr2mod
# ============================================================
class ReactosFindModuleByAddr(gdb.Command):
"""Find which module an address belongs to. Usage: ros-addr2mod <addr>"""
def __init__(self):
super().__init__("ros-addr2mod", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-addr2mod <address>")
return
target = int(arg.strip(), 0)
try:
list_head = int(gdb.parse_and_eval("(unsigned long long)&PsLoadedModuleList"))
except:
print("PsLoadedModuleList not found.")
return
flink = read_u64(list_head)
if flink is None:
return
entry_addr = flink
count = 0
while entry_addr != list_head and count < 256:
dll_base = read_u64(entry_addr + 0x30)
size = read_u32(entry_addr + 0x40)
name = read_unicode_string(entry_addr + 0x58)
if dll_base and size and dll_base <= target < dll_base + size:
offset = target - dll_base
name_str = name if name else "<unknown>"
print(f"0x{target:x} => {name_str} + 0x{offset:x} (base=0x{dll_base:x})")
return
entry_addr = read_u64(entry_addr)
if entry_addr is None:
break
count += 1
print(f"0x{target:x} not found in any loaded module")
ReactosFindModuleByAddr()
# ============================================================
# ros-thread, ros-waiters, ros-irp
# ============================================================
def dump_thread_stack(thread_addr):
init_stack = read_u64(thread_addr + KTHREAD_INITIAL_STACK)
kern_stack = read_u64(thread_addr + KTHREAD_KERNEL_STACK)
if not init_stack or not kern_stack:
print(f" Cannot read stack pointers")
return
print(f" KernelStack = 0x{kern_stack:x}")
print(f" InitialStack = 0x{init_stack:x}")
low = min(kern_stack, init_stack)
high = max(kern_stack, init_stack)
scan_len = min(high - low, 0x8000)
print(f" Stack span = {high - low} bytes\n")
for off in range(0, scan_len, 8):
addr = low + off
val = read_u64(addr)
if val is None:
continue
if val >= 0xFFFFF80000000000 and val < 0xFFFFFFFFFFC00000:
sym = addr_to_sym(val)
if sym:
print(f" [0x{addr:x}] 0x{val:x} {sym}")
class ReactosThreadInfo(gdb.Command):
"""Inspect a kernel thread. Usage: ros-thread <addr>"""
def __init__(self):
super().__init__("ros-thread", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-thread <kthread-address>")
return
thread = int(arg.strip(), 0)
print(f"=== KTHREAD 0x{thread:x} ===\n")
init_stack = read_u64(thread + KTHREAD_INITIAL_STACK)
kern_stack = read_u64(thread + KTHREAD_KERNEL_STACK)
print(f"InitialStack = 0x{init_stack:x}" if init_stack else "InitialStack = ?")
print(f"KernelStack = 0x{kern_stack:x}" if kern_stack else "KernelStack = ?")
apc_base = thread + KTHREAD_APC_STATE
process = read_u64(apc_base + KAPC_STATE_PROCESS)
kapc_disable = read_i16(thread + KTHREAD_KERNEL_APC_DISABLE)
special_disable = read_i16(thread + KTHREAD_SPECIAL_APC_DISABLE)
kapc_pending = read_u8(apc_base + KAPC_STATE_KAPC_PENDING)
print(f"\nAPC State:")
print(f" KernelApcDisable = {kapc_disable}")
print(f" SpecialApcDisable = {special_disable}")
print(f" KernelApcPending = {kapc_pending}")
print(f" Process = 0x{process:x}" if process else " Process = ?")
print(f"\nStack trace:")
dump_thread_stack(thread)
ReactosThreadInfo()
class ReactosFindWaiters(gdb.Command):
"""Find threads waiting on a dispatcher object. Usage: ros-waiters <addr>"""
def __init__(self):
super().__init__("ros-waiters", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-waiters <event-address>")
return
event = int(arg.strip(), 0)
hdr = read_mem(event, 4)
if not hdr:
print("Cannot read object header")
return
obj_type = hdr[0]
signal_state = read_u32(event + 4)
wait_list_head = event + 8
flink = read_u64(wait_list_head)
type_names = {0: "NotificationEvent", 1: "SynchronizationEvent",
2: "Mutant", 3: "Process", 5: "Thread",
8: "NotificationTimer", 9: "SynchronizationTimer",
29: "Semaphore"}
print(f"Type={type_names.get(obj_type, f'?({obj_type})')} SignalState={signal_state}")
if not flink or flink == wait_list_head:
print("No waiters")
return
entry = flink
count = 0
while entry != wait_list_head and count < 32:
thread_ptr = read_u64(entry + KWAIT_BLOCK_THREAD)
print(f" Waiter: Thread=0x{thread_ptr:x}" if thread_ptr else " Waiter: Thread=NULL")
entry = read_u64(entry)
if entry is None:
break
count += 1
print(f"{count} waiter(s)")
ReactosFindWaiters()
class ReactosIrpInfo(gdb.Command):
"""Inspect an IRP. Usage: ros-irp <addr>"""
def __init__(self):
super().__init__("ros-irp", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-irp <irp-address>")
return
irp = int(arg.strip(), 0)
irp_type = read_u16(irp)
flags = read_u32(irp + 0x10)
user_event = read_u64(irp + 0x50)
user_buffer = read_u64(irp + 0x68)
thread = read_u64(irp + 0x78)
print(f"IRP 0x{irp:x}: Type=0x{irp_type:x} Flags=0x{flags:x}")
print(f" UserEvent=0x{user_event:x} UserBuffer=0x{user_buffer:x} Thread=0x{thread:x}")
ReactosIrpInfo()
# ============================================================
# ros-trapframes — also accepts CS=0x23 (compat mode)
# ============================================================
class ReactosFindTrapFrames(gdb.Command):
"""Scan stack for KTRAP_FRAMEs. Usage: ros-trapframes [lo] [hi]"""
def __init__(self):
super().__init__("ros-trapframes", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
args = arg.strip().split()
if len(args) >= 2:
low = int(args[0], 0)
high = int(args[1], 0)
elif len(args) == 1:
low = int(args[0], 0)
high = low + 0x2000
else:
try:
low = int(gdb.parse_and_eval("$rsp"))
except:
print("Cannot read RSP")
return
high = low + 0x4000
found = 0
for pos in range(low, high - TF_SIZE, 8):
cs_data = read_mem(pos + TF_SEG_CS, 2)
if cs_data is None:
continue
seg_cs = struct.unpack('<H', cs_data)[0]
# Accept kernel (0x10), user64 (0x33), and compat32 (0x23)
if seg_cs not in (0x10, 0x33, 0x23):
continue
rip = read_u64(pos + TF_RIP)
if rip is None:
continue
# For user/compat mode, RIP can be low; for kernel, must be KSEG0
if seg_cs == 0x10 and not (0xFFFFF80000000000 <= rip <= 0xFFFFFFFFFFC00000):
continue
if seg_cs in (0x33, 0x23) and rip > 0x800000000000:
continue # user addresses should be low
rsp = read_u64(pos + TF_RSP) or 0
error_code = read_u64(pos + TF_ERROR_CODE) or 0
rax = read_u64(pos + TF_RAX) or 0
rcx = read_u64(pos + TF_RCX) or 0
sym = addr_to_sym(rip)
sym_str = f" ({sym})" if sym else ""
cs_name = {0x10: "kernel", 0x33: "user64", 0x23: "compat32"}.get(seg_cs, "?")
found += 1
print(f"KTRAP_FRAME @ 0x{pos:x}:")
print(f" Rip = 0x{rip:x}{sym_str}")
print(f" CS = 0x{seg_cs:x} ({cs_name})")
print(f" Rsp = 0x{rsp:x}")
print(f" ErrorCode = 0x{error_code:x}")
print(f" Rax=0x{rax:x} Rcx=0x{rcx:x}")
print()
if found == 0:
print("No KTRAP_FRAMEs found.")
else:
print(f"{found} trap frame(s) found.")
ReactosFindTrapFrames()
# ============================================================
# ros-callchain
# ============================================================
class ReactosCallChain(gdb.Command):
"""Heuristic stack scan for return addresses. Usage: ros-callchain [rsp] [depth]"""
def __init__(self):
super().__init__("ros-callchain", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
args = arg.strip().split()
try:
rsp = int(args[0], 0) if args else int(gdb.parse_and_eval("$rsp"))
except:
print("Cannot read RSP")
return
depth = int(args[1], 0) if len(args) >= 2 else 0x400
# Build module ranges
ranges = []
try:
list_head = int(gdb.parse_and_eval("(unsigned long long)&PsLoadedModuleList"))
flink = read_u64(list_head)
entry = flink
count = 0
while entry and entry != list_head and count < 256:
dll_base = read_u64(entry + 0x30)
size = read_u32(entry + 0x40)
name = read_unicode_string(entry + 0x58)
if dll_base and size:
ranges.append((dll_base, dll_base + size, name or "?"))
entry = read_u64(entry)
count += 1
except:
pass
print(f"Scanning 0x{rsp:x} - 0x{rsp + depth:x}:")
for off in range(0, depth, 8):
val = read_u64(rsp + off)
if val is None:
continue
mod = None
for lo, hi, n in ranges:
if lo <= val < hi:
mod = n
break
if mod is None:
if not ranges and 0xFFFFF80000000000 <= val <= 0xFFFFF8FFFFFFFFFF:
sym = addr_to_sym(val)
if sym:
print(f" [RSP+0x{off:03x}] 0x{val:x} {sym}")
continue
sym = addr_to_sym(val)
sym_str = sym if sym else f"{mod}+?"
print(f" [RSP+0x{off:03x}] 0x{val:x} {sym_str}")
ReactosCallChain()
# ============================================================
# ros-kdb-bt
# ============================================================
# Symbols that belong to the kdb / RtlAssert / int-0x2c preamble.
# These are skipped when walking from the parked KDB stack back to
# whoever called RtlAssert. Match by symbol prefix (left-anchored).
_KDB_PREAMBLE_PREFIXES = (
"Cp16550", "CpGetByte", "CpPutByte",
"KdPortGetByte", "KdPortPutByte",
"KdbpTryGetChar", "KdbpGetChar", "KdpReadTermKey",
"KdIoReadLine", "KdIoPrint", "KdIo",
"KdReceivePacket", "KdSendPacket", "KdpSendString",
"KdbgReceivePacket", "KdbgSendPacket",
"KdpPrompt", "KdpPromptString", "KdpTrap", "KdpReport",
"KdpGetContext", "KdpSetContext",
"KdpSendWaitContinue", "KdpReportExceptionStateChange",
"KdEnterDebugger", "KdExitDebugger",
"KdDebuggerNotPresent", "KdpContext",
"KiDispatchException", "InternalDispatchException",
"KiDebugServiceTrap", "KiSystemCall64",
"DebugService", "DbgPrompt", "DbgPrint", "DbgUserBreakPoint",
"RtlAssert", "vDbgPrintEx", "TraceDataBuffer",
"__FUNCTION__", "MmSysPteIndex", # arg / rodata neighbours
"KiInitialPcr", "KiProcessorBlock", # neighbour symbols at +/- offsets
"KiP0DoubleFault", # double-fault stack data
)
def _is_preamble_sym(sym):
if not sym:
return True
bare = sym.split(" + ", 1)[0]
return any(bare.startswith(p) for p in _KDB_PREAMBLE_PREFIXES)
def _stack_kernel_returns(rsp, depth_bytes, ranges):
"""Yield (offset, value, sym, module) for every plausible kernel-text
return-address slot in [rsp, rsp+depth_bytes)."""
for off in range(0, depth_bytes, 8):
val = read_u64(rsp + off)
if val is None:
continue
# Plausible kernel-text addresses: ntoskrnl 0xfffff800.. + drivers 0xfffff880..
if not (0xFFFFF80000000000 <= val <= 0xFFFFFFFFFFFFFFFF):
continue
mod = None
for lo, hi, n in ranges:
if lo <= val < hi:
mod = n
break
if mod is None:
# Accept ntoskrnl-range even without module table (early boot etc).
if not (0xFFFFF80000000000 <= val <= 0xFFFFF8FFFFFFFFFF):
continue
sym = addr_to_sym(val)
if not sym:
continue
yield off, val, sym, mod
def _build_module_ranges():
ranges = []
try:
list_head = int(gdb.parse_and_eval("(unsigned long long)&PsLoadedModuleList"))
flink = read_u64(list_head)
entry = flink
count = 0
while entry and entry != list_head and count < 256:
dll_base = read_u64(entry + 0x30)
size = read_u32(entry + 0x40)
name = read_unicode_string(entry + 0x58)
if dll_base and size:
ranges.append((dll_base, dll_base + size, name or "?"))
entry = read_u64(entry)
count += 1
except Exception:
pass
return ranges
def _thread_stack_has_kdb(rsp, ranges, scan_bytes=0x2000):
"""Return True if this thread's stack contains a frame in KdpPrompt/
KdReceivePacket/KdpTrap path — i.e. it's the CPU parked in kdb."""
needles = ("KdpPrompt", "KdReceivePacket", "KdpTrap", "KdIoReadLine",
"KdbpTryGetChar", "Cp16550ReadLsr")
seen = 0
for off, val, sym, mod in _stack_kernel_returns(rsp, scan_bytes, ranges):
bare = sym.split(" + ", 1)[0]
if any(bare.startswith(n) for n in needles):
seen += 1
if seen >= 2:
return True
return False
def _looks_like_trap_frame(addr):
"""Heuristic: at `addr` does memory match a KTRAP_FRAME shape with a
plausible kernel/user RIP and CS in {0x10, 0x33, 0x23, 0x1b}?"""
if not addr or addr < 0xFFFFF80000000000:
return False
rip = read_u64(addr + TF_RIP)
cs = read_u64(addr + TF_SEG_CS)
rsp = read_u64(addr + TF_RSP)
if rip is None or cs is None or rsp is None:
return False
cs &= 0xFFFF
if cs not in (0x10, 0x33, 0x23, 0x1b):
return False
# RIP must look like code (kernel or 64-bit user).
if not ((0xFFFFF80000000000 <= rip <= 0xFFFFFFFFFFFFFFFF) or
(0x0000000000400000 <= rip <= 0x00007FFFFFFFFFFF)):
return False
return True
def _find_assert_trapframe(kdb_rsp, ranges, scan_bytes=0x4000):
"""Scan the kdb-side stack for a pointer to a KTRAP_FRAME whose RIP
is in the DebugService / RtlAssert / KiBreakpointTrap preamble. Return
the trap frame address, or None."""
for off in range(0, scan_bytes, 8):
val = read_u64(kdb_rsp + off)
if val is None or val < 0xFFFFF80000000000:
continue
if not _looks_like_trap_frame(val):
continue
rip = read_u64(val + TF_RIP)
sym = addr_to_sym(rip) or ""
bare = sym.split(" + ", 1)[0]
if bare.startswith(("DebugService", "RtlAssert", "DbgPrompt",
"KiBreakpointTrap", "DbgUserBreakPoint",
"DbgPrint")):
return val
return None
# CONTEXT (amd64) field offsets used to read the asserter's RSP/RIP.
CTX_RSP = 0x98
CTX_RIP = 0xF8
def _get_kdbg_context_rsp():
"""Return (rsp, rip) from the global KdbgKdContext CONTEXT structure
(the asserter's saved register state at the trap), or (None, None)."""
try:
ctx_addr = int(gdb.parse_and_eval("(unsigned long long)&KdbgKdContext"))
except Exception:
return None, None
rsp = read_u64(ctx_addr + CTX_RSP)
rip = read_u64(ctx_addr + CTX_RIP)
return rsp, rip
def _gdb_bt_lines(depth):
try:
out = gdb.execute(f"bt {depth}", to_string=True)
except Exception as e:
return [f"(bt failed: {e})"]
return [ln.rstrip() for ln in out.splitlines() if ln.strip()]
_BT_PREAMBLE_FUNCS = (
"Cp16550ReadLsr", "Uart16550GetByte", "CpGetByte", "CpPutByte",
"KdPortGetByteEx", "KdbpTryGetCharSerial", "KdpReadTermKey",
"KdIoReadLine", "KdReceivePacket", "KdbgReceivePacket",
"KdpPromptString", "KdpPrompt", "KdpTrap", "KdpReport",
"KdpPrintString",
"KiDispatchException", "InternalDispatchException",
"KiDebugServiceTrap", "DebugService",
"DbgPrompt", "DbgPrint", "DbgUserBreakPoint",
"RtlAssert", "vDbgPrintEx",
"KiProcessorFreezeHandler", "KiNmiInterruptHandler",
"KiNmiInterruptWithEf", "KiNmiInterrupt",
)
def _bt_line_is_preamble(line):
# Match "#N 0x... in FunctionName (...)" or "#N FunctionName (...)"
m = _re.search(r'(?:in\s+)([A-Za-z_][A-Za-z0-9_]*)', line)
if not m:
# Also match "#N FunctionName (" (no 'in' prefix when frame has no addr).
m = _re.search(r'^#\d+\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(', line)
if not m:
return False
return m.group(1) in _BT_PREAMBLE_FUNCS
class ReactosKdbBacktrace(gdb.Command):
"""Show the asserting callchain from a kdb-reached assert, stripping
the RtlAssert / DbgPrompt / int 0x2c preamble.
Usage: ros-kdb-bt [rsp] [depth]
With no args, auto-selects the kdb-parked CPU, runs `bt 60` on it,
and prints the frames past the kdb preamble. With [rsp] supplied,
falls back to the heuristic stack scan used by ros-callchain.
Run after `ros-connect` while target is halted at a kdb prompt."""
def __init__(self):
super().__init__("ros-kdb-bt", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
args = arg.strip().split()
ranges = _build_module_ranges()
rsp = None
if args:
try:
rsp = int(args[0], 0)
except Exception:
print(f"Bad rsp arg: {args[0]}")
return
depth = int(args[1], 0) if len(args) >= 2 else 0x4000
if rsp is None:
# Auto-find the kdb-parked thread, then chase the trap frame
# off the dedicated KdbStack back to the asserter's stack.
saved = gdb.selected_thread()
kdb_thread = None
kdb_rsp = None
for inf in gdb.inferiors():
for thr in inf.threads():
try:
thr.switch()
cur_rsp = int(gdb.parse_and_eval("(unsigned long long)$rsp"))
if _thread_stack_has_kdb(cur_rsp, ranges):
kdb_thread = thr
kdb_rsp = cur_rsp
break
except Exception:
continue
if kdb_thread:
break
if kdb_thread is None:
print("Could not locate a CPU parked in kdb. Pass [rsp] explicitly.")
if saved:
saved.switch()
return
# GDB's unwinder has CFI for the kernel and can walk across
# the kdb stack swap. Use `bt` and filter the preamble.
print(f"Parked CPU: thread #{kdb_thread.num} RSP=0x{kdb_rsp:x}")
lines = _gdb_bt_lines(60)
# Strip the leading kdb-prompt preamble frames.
start = 0
while start < len(lines) and _bt_line_is_preamble(lines[start]):
start += 1
# GDB's unwinder stops at KdbpStackSwitchAndCall (asm stub
# that swaps to the asserter's process stack). The next
# frame line shows the asserter's saved RSP as if it were
# a RIP. Find it and continue with a heuristic scan from
# the asserter's stack.
swap_idx = next((i for i, ln in enumerate(lines)
if "KdbpStackSwitchAndCall" in ln), None)
asserter_rsp = None
if swap_idx is not None and swap_idx + 1 < len(lines):
m = _re.search(r'#\d+\s+(0x[0-9a-fA-F]+)\s+in\s+\?\?',
lines[swap_idx + 1])
if m:
cand = int(m.group(1), 0)
if 0xFFFFF80000000000 <= cand < 0xFFFFFFFFFFFFFFFF:
asserter_rsp = cand
print(f"Stripped {start} kdb/assert preamble frames.")
print("=== Asserting callchain (gdb-unwound, innermost first) ===")
for i, ln in enumerate(lines[start:start+30]):
ln2 = _re.sub(r'^#\d+\s+', f'#{i:<2} ', ln)
print(" " + ln2)
if len(lines) - start > 30:
print(f" ... ({len(lines) - start - 30} more, raise [depth] or use plain `bt`)")
# Continue past the stack swap into the asserter's frames.
if asserter_rsp:
print("")
print(f"=== Asserter's stack (RSP=0x{asserter_rsp:x}, heuristic) ===")
frames = list(_stack_kernel_returns(asserter_rsp, depth, ranges))
chain = []
for off, val, sym, mod in frames:
if chain and chain[-1][2] == sym:
continue
# Skip the kdb stack-swap helpers themselves.
bare = sym.split(" + ", 1)[0]
if bare.startswith(("KdbpStackSwitchAndCall",
"KdbpCallMainLoop",
"KdbEnterDebuggerException",
"KdbpCliMainLoop", "KdbPrompt",
"KdbPromptString",
"Kdbg", "KdbpAttachToThread",
"KdbpStallExecutionProcessor")):
continue
chain.append((off, val, sym, mod))
# Now strip any residual RtlAssert/DbgPrompt/etc preamble.
istart = 0
while istart < len(chain) and _is_preamble_sym(chain[istart][2]):
istart += 1
printed = 0
for off, val, sym, mod in chain[istart:]:
tag = f"[{mod}]" if mod else ""
print(f" [RSP+0x{off:04x}] 0x{val:x} {sym} {tag}")
printed += 1
if printed >= 60:
print(" ... (truncated)")
break
return
# Explicit rsp supplied — fall back to heuristic stack scan.
# Walk: collect every kernel return on the stack in order, then
# strip the leading preamble run. The first non-preamble symbol
# is the asserting function.
frames = list(_stack_kernel_returns(rsp, depth, ranges))
# Dedupe consecutive duplicates that come from compiler-emitted
# double-pushes of return addresses (RtlAssert + 39 / + 188 etc).
chain = []
for off, val, sym, mod in frames:
if chain and chain[-1][2] == sym:
continue
chain.append((off, val, sym, mod))
# Find first non-preamble entry.
start = 0
while start < len(chain) and _is_preamble_sym(chain[start][2]):
start += 1
if start == 0:
print("(no preamble frames matched — output is raw)")
else:
print(f"Stripped {start} kdb/assert preamble frames.")
print(f"Scanning 0x{rsp:x} - 0x{rsp + depth:x} ({len(chain)} kernel-text slots)")
# Print the cleaned chain.
print("=== Asserting callchain (innermost first) ===")
printed = 0
for off, val, sym, mod in chain[start:]:
tag = f"[{mod}]" if mod else ""
print(f" [RSP+0x{off:04x}] 0x{val:x} {sym} {tag}")
printed += 1
if printed >= 30:
print(" ... (truncated; pass larger [depth] for more)")
break
ReactosKdbBacktrace()
# ============================================================
# ros-findmod
# ============================================================
class ReactosFindModule(gdb.Command):
"""Find module base by scanning for MZ/PE. Usage: ros-findmod <addr>"""
def __init__(self):
super().__init__("ros-findmod", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-findmod <address>")
return
target = int(arg.strip(), 0)
page = target & ~0xFFF
for i in range(4096):
sig = read_u16(page)
if sig == 0x5A4D:
pe_off = read_u32(page + 0x3C)
if pe_off and pe_off < 0x1000:
pe_sig = read_u32(page + pe_off)
if pe_sig == 0x00004550:
print(f"Module base: 0x{page:x}")
sections = get_pe_all_sections(page)
for sname, rva in sections:
print(f" {sname:8s} @ 0x{page + rva:x}")
return
page -= 0x1000
print(f"No MZ/PE found scanning back from 0x{target:x}")
ReactosFindModule()
# ============================================================
# ros-pslist, ros-findproc
# ============================================================
def _walk_eprocesses(max_count=128):
try:
head_addr = int(gdb.parse_and_eval("(unsigned long long)&PsActiveProcessHead"))
except:
print("PsActiveProcessHead not found.")
return
flink = read_u64(head_addr)
if flink is None or flink == 0:
return
seen = set()
e = flink
while e and e != head_addr and e not in seen and len(seen) < max_count:
seen.add(e)
eproc = e - EPROCESS_ACTIVE_PROCESS_LINKS
pid = read_u64(eproc + EPROCESS_UNIQUE_PROCESS_ID)
name = read_ascii_string(eproc + EPROCESS_IMAGE_FILE_NAME, 16)
yield (eproc, pid, name)
nxt = read_u64(e)
if nxt is None:
break
e = nxt
def _walk_process_threads(eproc, max_count=256):
list_head = eproc + EPROCESS_THREAD_LIST_HEAD
entry = read_u64(list_head)
seen = set()
count = 0
while entry and entry != list_head and entry not in seen and count < max_count:
seen.add(entry)
thread = entry - ETHREAD_THREAD_LIST_ENTRY
yield thread
entry = read_u64(entry)
count += 1
class ReactosProcessList(gdb.Command):
"""List active processes. Usage: ros-pslist"""
def __init__(self):
super().__init__("ros-pslist", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
count = 0
for eproc, pid, name in _walk_eprocesses():
count += 1
shown = name if name else "<no name>"
tcount = sum(1 for _ in _walk_process_threads(eproc))
print(f" {fmt_ptr(eproc)} pid={fmt_ptr(pid)} threads={tcount:2d} {shown}")
print(f"\n{count} active processes")
ReactosProcessList()
class ReactosFindProcess(gdb.Command):
"""Find process by name. Usage: ros-findproc <name>"""
def __init__(self):
super().__init__("ros-findproc", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
needle = arg.strip().lower()
if not needle:
print("Usage: ros-findproc <name>")
return
for eproc, pid, name in _walk_eprocesses():
if name and needle in name.lower():
print(f" {fmt_ptr(eproc)} pid={fmt_ptr(pid)} {name}")
class ReactosProcessThreads(gdb.Command):
"""List threads in an EPROCESS. Usage: ros-threads <eprocess-address>"""
def __init__(self):
super().__init__("ros-threads", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-threads <eprocess-address>")
return
eproc = int(arg.strip(), 0)
name = read_ascii_string(eproc + EPROCESS_IMAGE_FILE_NAME, 16) or "<no name>"
print(f"=== EPROCESS {fmt_ptr(eproc)} {name} ===")
for thread in _walk_process_threads(eproc):
init_stack = read_u64(thread + KTHREAD_INITIAL_STACK)
kern_stack = read_u64(thread + KTHREAD_KERNEL_STACK)
state = read_u8(thread + KTHREAD_STATE)
wait_reason = read_u8(thread + KTHREAD_WAIT_REASON)
pid = read_u64(thread + ETHREAD_CID)
tid = read_u64(thread + ETHREAD_CID + 8)
print(f" ETHREAD {fmt_ptr(thread)} pid={fmt_ptr(pid)} tid={fmt_ptr(tid)} "
f"state={state} wait={wait_reason} "
f"KernelStack={fmt_ptr(kern_stack)} InitialStack={fmt_ptr(init_stack)}")
dump_thread_stack(thread)
print("")
ReactosProcessThreads()
ReactosFindProcess()
# ============================================================
# ros-gdt — dump GDT entries (useful for WoW64 debugging)
# ============================================================
# ============================================================
# Physical memory + page table walker
# ============================================================
def read_phys(paddr, size=8):
"""Read physical memory via QEMU monitor xp command."""
try:
if size == 8:
out = gdb.execute(f"monitor xp/1gx 0x{paddr:x}", to_string=True)
elif size == 4:
out = gdb.execute(f"monitor xp/1wx 0x{paddr:x}", to_string=True)
else:
out = gdb.execute(f"monitor xp/{size}bx 0x{paddr:x}", to_string=True)
# Parse byte values
parts = out.split(':')[1].strip().split()
return bytes(int(x, 16) for x in parts[:size])
# Parse: "addr: 0xvalue"
val_str = out.split(':')[1].strip()
return int(val_str, 16)
except Exception as e:
return None
def virt_to_phys(cr3, vaddr):
"""Walk x86-64 4-level page tables to translate virtual to physical.
Returns (phys_addr, page_size) or (None, None) on failure."""
pml4_base = cr3 & 0xFFFFFFFFF000
pml4_idx = (vaddr >> 39) & 0x1FF
pdpt_idx = (vaddr >> 30) & 0x1FF
pd_idx = (vaddr >> 21) & 0x1FF
pt_idx = (vaddr >> 12) & 0x1FF
offset = vaddr & 0xFFF
# PML4
pml4e = read_phys(pml4_base + pml4_idx * 8)
if pml4e is None or not (pml4e & 1):
return None, None # not present
# PDPT
pdpt_base = pml4e & 0xFFFFFFFFF000
pdpte = read_phys(pdpt_base + pdpt_idx * 8)
if pdpte is None or not (pdpte & 1):
return None, None
if pdpte & 0x80: # 1GB page
phys = (pdpte & 0xFFFFFC0000000) | (vaddr & 0x3FFFFFFF)
return phys, 0x40000000
# PD
pd_base = pdpte & 0xFFFFFFFFF000
pde = read_phys(pd_base + pd_idx * 8)
if pde is None or not (pde & 1):
return None, None
if pde & 0x80: # 2MB page
phys = (pde & 0xFFFFFFFE00000) | (vaddr & 0x1FFFFF)
return phys, 0x200000
# PT
pt_base = pde & 0xFFFFFFFFF000
pte = read_phys(pt_base + pt_idx * 8)
if pte is None or not (pte & 1):
return None, None
phys = (pte & 0xFFFFFFFFF000) | offset
return phys, 0x1000
def read_virt_via_cr3(cr3, vaddr, size=8):
"""Read virtual memory from a specific address space (CR3)."""
phys, _ = virt_to_phys(cr3, vaddr)
if phys is None:
return None
return read_phys(phys, size)
class ReactosReadVirt(gdb.Command):
"""Read virtual memory from any process's address space.
Usage: ros-xp <cr3> <vaddr> [count]
Reads 8-byte values at vaddr using the given CR3 page tables.
Works even when the target process is not current."""
def __init__(self):
super().__init__("ros-xp", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
args = arg.strip().split()
if len(args) < 2:
print("Usage: ros-xp <cr3> <vaddr> [count]")
print(" cr3: page table base (from EPROCESS or trap frame)")
print(" vaddr: virtual address to read")
print(" count: number of 8-byte values (default 8)")
return
cr3 = int(args[0], 0)
vaddr = int(args[1], 0)
count = int(args[2], 0) if len(args) >= 3 else 8
for i in range(count):
addr = vaddr + i * 8
val = read_virt_via_cr3(cr3, addr)
if val is None:
print(f" 0x{addr:x}: <not mapped>")
else:
# Try to show as both hex and potential ASCII
print(f" 0x{addr:x}: 0x{val:016x}")
ReactosReadVirt()
class ReactosTranslate(gdb.Command):
"""Translate virtual address using page tables.
Usage: ros-translate <cr3> <vaddr>
Shows PML4/PDPT/PD/PT entries and final physical address."""
def __init__(self):
super().__init__("ros-translate", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
args = arg.strip().split()
if len(args) < 2:
print("Usage: ros-translate <cr3> <vaddr>")
return
cr3 = int(args[0], 0)
vaddr = int(args[1], 0)
pml4_base = cr3 & 0xFFFFFFFFF000
pml4_idx = (vaddr >> 39) & 0x1FF
pdpt_idx = (vaddr >> 30) & 0x1FF
pd_idx = (vaddr >> 21) & 0x1FF
pt_idx = (vaddr >> 12) & 0x1FF
offset = vaddr & 0xFFF
print(f"VA 0x{vaddr:x} with CR3=0x{cr3:x}")
print(f" Indices: PML4={pml4_idx} PDPT={pdpt_idx} PD={pd_idx} PT={pt_idx} Off=0x{offset:x}")
pml4e = read_phys(pml4_base + pml4_idx * 8)
if pml4e is None:
print(f" PML4[{pml4_idx}]: <read failed>")
return
p = "P" if pml4e & 1 else "NP"
rw = "RW" if pml4e & 2 else "RO"
us = "User" if pml4e & 4 else "Kern"
nx = "NX" if pml4e & (1 << 63) else ""
print(f" PML4[{pml4_idx}] = 0x{pml4e:016x} [{p} {rw} {us} {nx}]")
if not (pml4e & 1):
print(" -> NOT PRESENT")
return
pdpt_base = pml4e & 0xFFFFFFFFF000
pdpte = read_phys(pdpt_base + pdpt_idx * 8)
p = "P" if pdpte & 1 else "NP"
rw = "RW" if pdpte & 2 else "RO"
us = "User" if pdpte & 4 else "Kern"
ps = "1GB" if pdpte & 0x80 else ""
print(f" PDPT[{pdpt_idx}] = 0x{pdpte:016x} [{p} {rw} {us} {ps}]")
if not (pdpte & 1):
print(" -> NOT PRESENT")
return
if pdpte & 0x80:
phys = (pdpte & 0xFFFFFC0000000) | (vaddr & 0x3FFFFFFF)
print(f" -> 1GB page, phys = 0x{phys:x}")
return
pd_base = pdpte & 0xFFFFFFFFF000
pde = read_phys(pd_base + pd_idx * 8)
p = "P" if pde & 1 else "NP"
rw = "RW" if pde & 2 else "RO"
us = "User" if pde & 4 else "Kern"
ps = "2MB" if pde & 0x80 else ""
print(f" PD[{pd_idx}] = 0x{pde:016x} [{p} {rw} {us} {ps}]")
if not (pde & 1):
print(" -> NOT PRESENT")
return
if pde & 0x80:
phys = (pde & 0xFFFFFFFE00000) | (vaddr & 0x1FFFFF)
print(f" -> 2MB page, phys = 0x{phys:x}")
return
pt_base = pde & 0xFFFFFFFFF000
pte = read_phys(pt_base + pt_idx * 8)
p = "P" if pte & 1 else "NP"
rw = "RW" if pte & 2 else "RO"
us = "User" if pte & 4 else "Kern"
nx = "NX" if pte & (1 << 63) else ""
print(f" PT[{pt_idx}] = 0x{pte:016x} [{p} {rw} {us} {nx}]")
if not (pte & 1):
print(" -> NOT PRESENT")
return
phys = (pte & 0xFFFFFFFFF000) | offset
print(f" -> phys = 0x{phys:x}")
# Read the value at that physical address
val = read_phys(phys)
if val is not None:
print(f" -> value = 0x{val:016x}")
ReactosTranslate()
class ReactosDumpGdt(gdb.Command):
"""Dump GDT entries. Usage: ros-gdt [base] [limit]
If no args, reads GDTR from QEMU monitor socket at /tmp/wow64.sock
or tries known ReactOS default."""
def __init__(self):
super().__init__("ros-gdt", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
args = arg.strip().split()
if len(args) >= 2:
gdtbase = int(args[0], 0)
gdtlimit = int(args[1], 0)
else:
# Try to get from QEMU monitor
gdtbase = None
import subprocess
for sock in ['/tmp/wow64.sock', '/tmp/reactos-monitor.sock']:
try:
result = subprocess.run(
['socat', '-', f'UNIX-CONNECT:{sock}'],
input=b'info registers\n', capture_output=True, timeout=2)
for line in result.stdout.decode(errors='replace').split('\n'):
if 'GDT=' in line:
parts = line.split('GDT=')[1].strip().split()
gdtbase = int(parts[0], 16)
gdtlimit = int(parts[1], 16)
break
if gdtbase:
break
except:
pass
if gdtbase is None:
# Try known ReactOS default
gdtbase = 0xfffff80000099000
gdtlimit = 0x7ff
print(f"Using default GDT base 0x{gdtbase:x}")
NAMES = {
0x00: 'NULL', 0x08: '???', 0x10: 'R0_CODE', 0x18: 'R0_DATA',
0x20: 'R3_CMCODE', 0x28: 'R3_DATA', 0x30: 'R3_CODE',
0x40: 'TSS', 0x50: 'R3_CMTEB',
}
print(f"GDT base=0x{gdtbase:x} limit=0x{gdtlimit:x}\n")
for sel in range(0, min(gdtlimit + 1, 0x80), 8):
raw = read_mem(gdtbase + sel, 8)
if raw is None:
continue
w0, w1 = struct.unpack('<II', raw)
if w0 == 0 and w1 == 0 and sel != 0:
continue
base = ((w0 >> 16) & 0xFFFF) | ((w1 & 0xFF) << 16) | (((w1 >> 24) & 0xFF) << 24)
limit = (w0 & 0xFFFF) | (((w1 >> 16) & 0xF) << 16)
type_ = (w1 >> 8) & 0xF
s = (w1 >> 12) & 1
dpl = (w1 >> 13) & 3
p = (w1 >> 15) & 1
l = (w1 >> 21) & 1
db = (w1 >> 22) & 1
g = (w1 >> 23) & 1
name = NAMES.get(sel, '')
mode = "long64" if l else ("compat32" if db else "16bit")
print(f" 0x{sel:02x} {name:12s} base=0x{base:08x} lim=0x{limit:05x} "
f"type={type_:x} DPL={dpl} P={p} {mode} G={g}")
ReactosDumpGdt()
# ============================================================
# ros-fault — dump faulting context from KTRAP_FRAME in RBP
# ============================================================
KPCR_CURRENT_THREAD = 0x188
def _get_current_process_name():
"""Get the current process name from EPROCESS."""
try:
gs_base = int(gdb.parse_and_eval("$gs_base")) & 0xFFFFFFFFFFFFFFFF
kthread = read_u64(gs_base + KPCR_CURRENT_THREAD)
if not kthread:
return None
eproc = read_u64(kthread + KTHREAD_APC_STATE + KAPC_STATE_PROCESS)
if not eproc:
return None
blk = read_mem(eproc, 0x400)
if blk:
hits = _re.findall(rb"[A-Za-z0-9_.\\-]{2,20}\.exe", blk)
if hits:
return hits[0].decode("ascii")
except:
pass
return None
def _dump_trap_frame(tf, source=""):
"""Dump a KTRAP_FRAME. Returns False if tf is not a valid trap frame."""
rip = read_u64(tf + TF_RIP)
cs = read_u16(tf + TF_SEG_CS)
if cs is None or cs not in (0x10, 0x23, 0x33):
return False
rsp = read_u64(tf + TF_RSP)
ss = read_u16(tf + TF_SEG_SS)
err = read_u64(tf + TF_ERROR_CODE)
rax = read_u64(tf + TF_RAX)
rcx = read_u64(tf + TF_RCX)
rdx = read_u64(tf + TF_RDX)
r8 = read_u64(tf + TF_R8)
r9 = read_u64(tf + TF_R9)
rbx = read_u64(tf + TF_RBX)
rdi = read_u64(tf + TF_RDI)
rsi = read_u64(tf + TF_RSI)
rbp_val = read_u64(tf + TF_RBP)
eflags = read_u64(tf + TF_EFLAGS)
cr2 = read_u64(tf + TF_FAULT_ADDR)
ds = read_u16(tf + TF_SEG_DS)
es = read_u16(tf + TF_SEG_ES)
fs = read_u16(tf + TF_SEG_FS)
gs_seg = read_u16(tf + TF_SEG_GS)
cs_name = {0x10: "kernel", 0x33: "user64", 0x23: "compat32"}.get(cs, "?")
src_str = f" (from {source})" if source else ""
print(f"=== KTRAP_FRAME @ 0x{tf:x}{src_str} ===\n")
# Process name
proc_name = _get_current_process_name()
if proc_name:
print(f" Process: {proc_name}")
print(f" RIP = 0x{rip:x} (CS=0x{cs:x} {cs_name})" if rip is not None else " RIP = ?")
print(f" RSP = 0x{rsp:x} (SS=0x{ss:x})" if rsp is not None else " RSP = ?")
print(f" EFLAGS = 0x{eflags:x}" if eflags is not None else " EFLAGS = ?")
print(f" Error = 0x{err:x}" if err is not None else " Error = ?")
if err is not None and err != 0:
ext = err & 1
tbl = (err >> 1) & 3
idx = (err >> 3) & 0x1FFF
tbl_name = ["GDT", "IDT", "LDT", "IDT"][tbl]
print(f" -> selector index={idx} table={tbl_name} ext={ext}")
if cr2:
print(f" CR2 = 0x{cr2:x}")
def _p(name, val):
return f"0x{val:016x}" if val is not None else "?"
print(f"\n RAX={_p('rax',rax)} RBX={_p('rbx',rbx)}")
print(f" RCX={_p('rcx',rcx)} RDX={_p('rdx',rdx)}")
print(f" RSI={_p('rsi',rsi)} RDI={_p('rdi',rdi)}")
print(f" RBP={_p('rbp',rbp_val)} R8 ={_p('r8',r8)}")
print(f" R9 ={_p('r9',r9)}")
if ds is not None:
print(f" DS=0x{ds:x} ES=0x{es:x} FS=0x{fs:x} GS=0x{gs_seg:x}")
if rip:
sym = addr_to_sym(rip)
if sym:
print(f"\n Symbol: {sym}")
if rip:
print(f"\n Code at 0x{rip:x}:")
try:
if cs == 0x23:
gdb.execute("set architecture i386")
disas = gdb.execute(f"x/5i 0x{rip:x}", to_string=True)
print(disas.rstrip())
except Exception as e:
print(f" (cannot disassemble: {e})")
finally:
if cs == 0x23:
try: gdb.execute("set architecture i386:x86-64")
except: pass
if rsp:
print(f"\n Stack at RSP=0x{rsp:x}:")
try:
for i in range(8):
addr = rsp + i * 8
val = read_u64(addr)
if val is not None:
sym = addr_to_sym(val)
sym_str = f" {sym}" if sym else ""
print(f" [+0x{i*8:02x}] 0x{val:016x}{sym_str}")
except:
print(f" (cannot read)")
try:
cr3 = int(gdb.parse_and_eval("$cr3"))
print(f"\n CR3 = 0x{cr3:x}")
except:
pass
return True
class ReactosFaultContext(gdb.Command):
"""Dump faulting/caller context from KTRAP_FRAME.
Auto-detects source: tries RBP (trap handler), then KTHREAD.TrapFrame.
Usage: ros-fault [trapframe_addr]"""
def __init__(self):
super().__init__("ros-fault", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if arg.strip():
tf = int(arg.strip(), 0)
if not _dump_trap_frame(tf, "explicit"):
print(f"0x{tf:x} does not look like a valid KTRAP_FRAME")
return
# Try RBP (valid in trap handlers after EnterTrap)
try:
rbp = int(gdb.parse_and_eval("$rbp")) & 0xFFFFFFFFFFFFFFFF
if rbp > 0xFFFFF00000000000: # kernel stack range
cs_test = read_u16(rbp + TF_SEG_CS)
if cs_test in (0x10, 0x23, 0x33):
if _dump_trap_frame(rbp, "RBP"):
return
except:
pass
# Try KTHREAD.TrapFrame
try:
gs_base = int(gdb.parse_and_eval("$gs_base")) & 0xFFFFFFFFFFFFFFFF
kthread = read_u64(gs_base + KPCR_CURRENT_THREAD)
if kthread:
thread_tf = read_u64(kthread + KTHREAD_TRAP_FRAME)
if thread_tf:
print(f"KTHREAD=0x{kthread:x}")
if _dump_trap_frame(thread_tf, "KTHREAD.TrapFrame"):
return
except:
pass
print("No valid KTRAP_FRAME found (not in trap handler, no active syscall)")
ReactosFaultContext()
# ============================================================
# ros-frame-regs — recover pushed non-volatile registers
# ============================================================
class ReactosFrameRegs(gdb.Command):
"""Recover pushed non-volatile registers from a function's stack frame.
Usage: ros-frame-regs <rsp_at_fault> [prologue_pattern]
Patterns: cc-release, cc-uninit, ntfs-release
Or raw: "r14,rdi,rsi,rbx:0x38" """
PATTERNS = {
'cc-release': (['r14', 'rdi', 'rsi', 'rbx'], 0x38),
'cc-uninit': (['r14', 'rbp', 'rdi', 'rsi', 'rbx'], 0x20),
'ntfs-release': (['rdi', 'rsi', 'rbx'], 0x20),
}
def __init__(self):
super().__init__("ros-frame-regs", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
args = arg.strip().split()
if len(args) < 1:
print("Usage: ros-frame-regs <rsp> [pattern|reg,reg:sub_size]")
print("Patterns:", ', '.join(self.PATTERNS.keys()))
return
rsp = int(args[0], 0)
if len(args) >= 2:
pat_name = args[1]
if pat_name in self.PATTERNS:
regs, sub_size = self.PATTERNS[pat_name]
elif ':' in pat_name:
parts = pat_name.split(':')
regs = parts[0].split(',')
sub_size = int(parts[1], 0)
else:
print(f"Unknown pattern '{pat_name}'")
return
else:
regs, sub_size = ['rbx'], 0x20
base = rsp + sub_size
print(f"Stack frame (sub $0x{sub_size:x}, {len(regs)} pushes):")
for i, reg in enumerate(reversed(regs)):
addr = base + i * 8
val = read_u64(addr)
val_str = f"0x{val:x}" if val is not None else "?"
sym = addr_to_sym(val) if val and val > 0xFFFFF80000000000 else None
sym_str = f" ({sym})" if sym else ""
print(f" saved_{reg:4s} @ 0x{addr:x} = {val_str}{sym_str}")
ret_addr_loc = base + len(regs) * 8
ret_val = read_u64(ret_addr_loc)
if ret_val:
sym = addr_to_sym(ret_val)
sym_str = f" ({sym})" if sym else ""
print(f" return @ 0x{ret_addr_loc:x} = 0x{ret_val:x}{sym_str}")
ReactosFrameRegs()
# ============================================================
# ros-verify — compare loaded binary vs build
# ============================================================
class ReactosVerifyBinary(gdb.Command):
"""Compare loaded module .text vs build on disk.
Usage: ros-verify <module_base> <build_path>"""
def __init__(self):
super().__init__("ros-verify", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
import hashlib
args = arg.strip().split(None, 1)
if len(args) < 2:
print("Usage: ros-verify <module_base> <build_path>")
return
base = int(args[0], 0)
rel_path = args[1]
filepath = os.path.join(BUILD_DIR, rel_path) if not os.path.isabs(rel_path) else rel_path
if not os.path.exists(filepath):
print(f"ERROR: {filepath} not found")
return
sections = get_pe_all_sections(base)
text_rva = None
for sname, rva in sections:
if sname == '.text':
text_rva = rva
break
if text_rva is None:
print("ERROR: No .text section found")
return
pe_off = read_u32(base + 0x3C)
num_sections = read_u16(base + pe_off + 6)
size_opt = read_u16(base + pe_off + 20)
sec_start = base + pe_off + 24 + size_opt
text_size = None
for i in range(num_sections):
sec = sec_start + i * 40
sec_name = read_mem(sec, 8)
if sec_name and sec_name.rstrip(b'\x00') == b'.text':
text_size = read_u32(sec + 8)
break
if text_size is None or text_size == 0:
print("ERROR: Cannot determine .text size")
return
print(f"Comparing .text (RVA=0x{text_rva:x}, size=0x{text_size:x})...")
loaded_data = bytearray()
chunk = 4096
for off in range(0, text_size, chunk):
sz = min(chunk, text_size - off)
d = read_mem(base + text_rva + off, sz)
if d is None:
print(f" ERROR: Memory read failed at base+0x{text_rva + off:x}")
return
loaded_data.extend(d)
loaded_hash = hashlib.sha256(bytes(loaded_data)).hexdigest()
with open(filepath, 'rb') as f:
pe_data = f.read(0x400)
disk_pe_off = struct.unpack_from('<I', pe_data, 0x3C)[0]
disk_num_sec = struct.unpack_from('<H', pe_data, disk_pe_off + 6)[0]
disk_opt_size = struct.unpack_from('<H', pe_data, disk_pe_off + 20)[0]
disk_sec_start = disk_pe_off + 24 + disk_opt_size
for i in range(disk_num_sec):
sec_off = disk_sec_start + i * 40
if sec_off + 40 > len(pe_data):
f.seek(0)
pe_data = f.read(sec_off + 40)
sec_name = pe_data[sec_off:sec_off + 8].rstrip(b'\x00')
if sec_name == b'.text':
disk_vsize = struct.unpack_from('<I', pe_data, sec_off + 8)[0]
disk_raw_off = struct.unpack_from('<I', pe_data, sec_off + 20)[0]
f.seek(disk_raw_off)
disk_text = f.read(min(disk_vsize, text_size))
break
else:
print(" ERROR: .text not found in build file")
return
build_hash = hashlib.sha256(disk_text).hexdigest()
if loaded_hash == build_hash:
print(f" MATCH: {loaded_hash[:16]}...")
else:
print(f" MISMATCH!")
print(f" Loaded: {loaded_hash[:32]}...")
print(f" Build: {build_hash[:32]}...")
ReactosVerifyBinary()
# ============================================================
# Pool debugging helpers
# ============================================================
def pool_tag_str(raw_bytes):
return ''.join(chr(b) if 32 <= b < 127 else '.' for b in raw_bytes)
def parse_pool_header(addr):
data = read_mem(addr, POOL_BLOCK_SIZE)
if data is None:
return None
return {
'addr': addr,
'prev_size': data[0], 'pool_index': data[1],
'block_size': data[2], 'pool_type': data[3],
'tag': pool_tag_str(data[4:8]), 'tag_bytes': data[4:8],
'actual_size': data[2] * POOL_BLOCK_SIZE,
'free': data[3] == 0,
}
def is_valid_pool_tag(tag_bytes):
for b in tag_bytes:
if b == 0: continue
if b < 0x20 or b > 0x7e: return False
return True
def is_pool_page(page_addr):
data = read_mem(page_addr, 0x1000)
if data is None: return False
if data[0] != 0 or data[2] == 0 or data[1] > 16: return False
if not is_valid_pool_tag(data[4:8]): return False
pos, prev_bs = 0, 0
for idx in range(5):
if pos >= 0x1000: break
bs = data[pos + 2]
if bs == 0: return pos == 0
if idx > 0 and data[pos] != prev_bs: return False
if not is_valid_pool_tag(data[pos + 4:pos + 8]): return False
prev_bs = bs
pos += bs * POOL_BLOCK_SIZE
return True
def walk_pool_page(page_addr):
blocks, errors = [], []
pos, prev_bs, idx = 0, 0, 0
while pos < 0x1000:
hdr = parse_pool_header(page_addr + pos)
if hdr is None:
errors.append(f"Cannot read header at +0x{pos:03x}")
break
hdr['offset'] = pos
hdr['index'] = idx
if idx > 0 and hdr['prev_size'] != prev_bs:
errors.append(f"Block #{idx} at +0x{pos:03x}: PrevSize={hdr['prev_size']} != {prev_bs}")
elif idx == 0 and hdr['prev_size'] != 0:
errors.append(f"Block #0: PrevSize={hdr['prev_size']} != 0")
if hdr['block_size'] == 0:
errors.append(f"Block #{idx}: BlockSize=0")
blocks.append(hdr)
break
blocks.append(hdr)
prev_bs = hdr['block_size']
pos += hdr['actual_size']
idx += 1
if idx > 256:
errors.append("Too many blocks (>256)")
break
if pos != 0x1000 and not errors:
errors.append(f"Blocks end at +0x{pos:03x}, expected +0x1000")
return blocks, errors
class ReactosPoolPage(gdb.Command):
"""Walk pool blocks on a page. Usage: ros-pool-page <addr>"""
def __init__(self):
super().__init__("ros-pool-page", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-pool-page <address>")
return
addr = int(arg.strip(), 0)
page = addr & ~0xFFF
print(f"=== Pool page 0x{page:x} ===\n")
blocks, errors = walk_pool_page(page)
for b in blocks:
status = "FREE" if b['free'] else f"pt={b['pool_type']}"
marker = " <<<" if page + b['offset'] <= addr < page + b['offset'] + b['actual_size'] else ""
print(f" #{b['index']:3d} +0x{b['offset']:03x} [{b['tag']:4s}] "
f"bs={b['block_size']:3d} (0x{b['actual_size']:03x}) ps={b['prev_size']:3d} {status}{marker}")
if errors:
print(f"\n*** {len(errors)} error(s):")
for e in errors: print(f" {e}")
else:
print(f"\n{len(blocks)} blocks, no errors.")
ReactosPoolPage()
class ReactosPoolBlock(gdb.Command):
"""Inspect a pool block. Usage: ros-pool-block <addr>"""
def __init__(self):
super().__init__("ros-pool-block", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-pool-block <address>")
return
data_addr = int(arg.strip(), 0)
hdr_addr = data_addr - POOL_BLOCK_SIZE
hdr = parse_pool_header(hdr_addr)
if hdr is None:
print(f"Cannot read pool header at 0x{hdr_addr:x}")
return
print(f"Pool block 0x{data_addr:x}: tag='{hdr['tag']}' bs={hdr['block_size']} "
f"(0x{hdr['actual_size']:x}) {'FREE' if hdr['free'] else 'alloc'}")
data_size = min(hdr['actual_size'] - POOL_BLOCK_SIZE, 256)
if data_size > 0:
data = read_mem(data_addr, data_size)
if data:
for i in range(0, len(data), 16):
chunk = data[i:i+16]
hex_part = ' '.join(f'{b:02x}' for b in chunk).ljust(47)
ascii_part = ''.join(chr(b) if 32 <= b < 127 else '.' for b in chunk)
print(f" +0x{i:03x}: {hex_part} {ascii_part}")
ReactosPoolBlock()
class ReactosPoolScan(gdb.Command):
"""Scan pool pages for corruption. Usage: ros-pool-scan <start> [num_pages]"""
def __init__(self):
super().__init__("ros-pool-scan", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
args = arg.strip().split()
if len(args) < 1:
print("Usage: ros-pool-scan <start_page> [num_pages]")
return
start = int(args[0], 0) & ~0xFFF
num = int(args[1], 0) if len(args) >= 2 else 256
print(f"Scanning {num} pages from 0x{start:x}...")
corrupt, ok, skip = 0, 0, 0
for i in range(num):
page = start + i * 0x1000
if not is_pool_page(page):
skip += 1
continue
blocks, errors = walk_pool_page(page)
if errors:
corrupt += 1
print(f"\n CORRUPT: 0x{page:x}")
for e in errors: print(f" {e}")
else:
ok += 1
print(f"\nDone: {ok} OK, {corrupt} CORRUPT, {skip} skipped")
ReactosPoolScan()
class ReactosPoolFind(gdb.Command):
"""Find pool blocks by tag. Usage: ros-pool-find <tag> <start> [num_pages]"""
def __init__(self):
super().__init__("ros-pool-find", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
args = arg.strip().split()
if len(args) < 2:
print("Usage: ros-pool-find <tag> <start_page> [num_pages]")
return
tag = args[0][:4].ljust(4)
start = int(args[1], 0) & ~0xFFF
num = int(args[2], 0) if len(args) >= 3 else 256
found = 0
for i in range(num):
page = start + i * 0x1000
if not is_pool_page(page): continue
blocks, _ = walk_pool_page(page)
for b in blocks:
if b['tag'] == tag:
found += 1
print(f" 0x{page + b['offset'] + POOL_BLOCK_SIZE:x} "
f"bs={b['block_size']:3d} {'FREE' if b['free'] else 'alloc'}")
print(f"\n{found} block(s) found.")
ReactosPoolFind()
class ReactosPoolCrash(gdb.Command):
"""Auto-analyze pool corruption from crash stack. Usage: ros-pool-crash"""
def __init__(self):
super().__init__("ros-pool-crash", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
try:
rsp = int(gdb.parse_and_eval("$rsp"))
except:
print("Cannot read RSP")
return
print("=== Pool crash analysis ===\n")
pool_addrs = set()
for off in range(0, 0x800, 8):
val = read_u64(rsp + off)
if val and 0xFFFFFA8000000000 <= val < 0xFFFFFA8100000000:
pool_addrs.add(val)
if not pool_addrs:
print("No pool addresses found on stack.")
return
pages = sorted(set(a & ~0xFFF for a in pool_addrs))
for page in pages:
print(f"\n--- Page 0x{page:x} ---")
blocks, errors = walk_pool_page(page)
page_refs = [a for a in pool_addrs if a & ~0xFFF == page]
for b in blocks:
status = "FREE" if b['free'] else f"pt={b['pool_type']}"
blk_start = page + b['offset']
refs = [a for a in page_refs if blk_start <= a < blk_start + b['actual_size']]
marker = f" <<< {', '.join(f'0x{a:x}' for a in refs)}" if refs else ""
print(f" #{b['index']:3d} +0x{b['offset']:03x} [{b['tag']:4s}] "
f"bs={b['block_size']:3d} ps={b['prev_size']:3d} {status}{marker}")
if errors:
print(f"\n *** CORRUPTION:")
for e in errors: print(f" {e}")
print(f"\n--- Call chain ---")
for off in range(0, 0x600, 8):
val = read_u64(rsp + off)
if val and KSEG0_BASE <= val <= 0xFFFFFFFFFFC00000:
sym = addr_to_sym(val)
if sym:
print(f" [RSP+0x{off:03x}] {sym}")
ReactosPoolCrash()
# ============================================================
# ros-rmap-arm — arm hbreaks for the MM rmap leak diagnostic
# ============================================================
# Sets four hardware breakpoints (KVM gdbstub limit) on:
# MmInsertRmap, MmDeleteRmap, MmCleanProcessAddressSpace,
# procsup.c:1378 (the "Might leak resources" early-return path).
# Each emits a single printf line to gdb's logging file. Use with the helper
# script /tmp/rmap_diag2.gdb (which sets up logging) or after manually
# enabling: set logging file <path>; set logging on.
#
# Addresses are computed from ntoskrnl runtime base via known RVAs.
# To override the base, pass it as an argument: ros-rmap-arm 0xfffff80000400000
class ReactosRmapArm(gdb.Command):
"""Arm rmap-leak diagnostic breakpoints on ntoskrnl.
Usage: ros-rmap-arm [ntoskrnl_runtime_base]
Default base is 0xFFFFF80000400000 (NT 6.2 amd64 ROS canonical).
Each BP emits an RMAP_* line via printf with no Python side-effects, so
gdb 17's known internal-error from python-in-bp-commands is avoided."""
# RVAs from x86_64-w64-mingw32-nm of build_nt62/ntoskrnl/ntoskrnl.exe
RVA_INSERT = 0xF7525 # MmInsertRmap entry
RVA_DELETE = 0xF77AD # MmDeleteRmap entry
RVA_CLEAN = 0xCB713 # MmCleanProcessAddressSpace entry
RVA_LEAK = 0xCB745 # procsup.c:1378 ("Might leak resources" early-return)
def __init__(self):
super().__init__("ros-rmap-arm", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
a = arg.strip()
base = int(a, 0) if a else 0xFFFFF80000400000
cmds = [
f"hbreak *0x{base + self.RVA_INSERT:x}",
"commands\nsilent\nprintf \"RMAP_INS p=%lx eproc=%lx va=%lx caller=%lx\\n\", $rcx, $rdx, $r8, *(unsigned long*)$rsp\ncontinue\nend",
f"hbreak *0x{base + self.RVA_DELETE:x}",
"commands\nsilent\nprintf \"RMAP_DEL p=%lx eproc=%lx va=%lx caller=%lx\\n\", $rcx, $rdx, $r8, *(unsigned long*)$rsp\ncontinue\nend",
f"hbreak *0x{base + self.RVA_CLEAN:x}",
"commands\nsilent\nprintf \"RMAP_CLEAN_ENTRY eproc=%lx flags2=0x%x rsp=%lx\\n\", $rcx, *(unsigned char*)($rcx+0x389), $rsp\ncontinue\nend",
f"hbreak *0x{base + self.RVA_LEAK:x}",
"commands\nsilent\nprintf \"RMAP_LEAK_EARLY_RETURN eproc=%lx asi=%d image=%s\\n\", $rbx, (*(unsigned char*)($rbx+0x389)>>2)&3, (char*)($rbx+0x2e0)\nbacktrace 25\nprintf \"RMAP_LEAK_END\\n\"\ncontinue\nend",
]
for c in cmds:
try:
gdb.execute(c, to_string=False)
except Exception as e:
print(f" failed: {c.splitlines()[0]}: {e}")
return
print(f"ros-rmap-arm: 4 hbreaks armed at base 0x{base:x}")
print(" Set 'set logging file <path>' / 'set logging on' before continue.")
ReactosRmapArm()
# ============================================================
# ARM3 PTE / PFN / proto-array helpers
# ============================================================
# Source-of-truth:
# sdk/include/ndk/amd64/mmtypes.h - MMPTE union (Hard/Soft/Trans/Proto/Subsect/List)
# ntoskrnl/include/internal/mm.h - MMPFN
# sdk/include/ndk/mmtypes.h - CONTROL_AREA, SUBSECTION
# sdk/include/xdk/amd64/mm.h - PTE_BASE = 0xFFFFF68000000000
# ntoskrnl/include/internal/amd64/mm.h - MiAddressToPte = PTE_BASE + (va>>12)*8
# = PTE_BASE + ((va>>9) & 0xFFFFFFFFF8)
#
# AMD64 MMPTE bit layout (NTDDI_LONGHORN+):
# bit 0 : Valid (Hard)
# bit 1 : Dirty1 (or Write) (Hard)
# bit 2 : Owner (Hard)
# bit 10 : Prototype
# bit 11 : Transition (Hard reserved0; Soft.Transition)
# For SOFTWARE PTEs (Valid=0):
# bit 0 : Valid (always 0 if soft)
# bits 1..4 : PageFileLow (4 bits) [Soft]
# bits 5..9 : Protection (5 bits) [Soft/Trans/Proto]
# bit 10 : Prototype [Soft/Trans/Proto]
# bit 11 : Transition [Soft/Trans]
# bits 32..63 : PageFileHigh (32 bits) [Soft]
# Proto form (Valid=0, Prototype=1, Transition=0):
# bits 16..63 : ProtoAddress (signed 48)
# Subsection form (Valid=0, Prototype=1, but used inside subsection PTEs):
# bits 16..63 : SubsectionAddress (signed 48)
# Trans form (Valid=0, Prototype=0, Transition=1):
# bits 12..47 : PageFrameNumber (36 bits, NTDDI_LONGHORN+)
#
# Bit positions verified against MMPTE_HARDWARE / MMPTE_SOFTWARE / MMPTE_PROTOTYPE
# in sdk/include/ndk/amd64/mmtypes.h.
PTE_BASE_AMD64 = 0xFFFFF68000000000
PTE_TOP_AMD64 = 0xFFFFF6FFFFFFFFFF
# MMPFN field offsets (amd64, MI_TRACE_PFNS off — ROS default)
PFN_U1 = 0x00 # 8 bytes (Flink/WsIndex/Event/...)
PFN_PTE_ADDRESS = 0x08 # PMMPTE
PFN_U2 = 0x10 # 8 bytes (Blink/ShareCount)
PFN_REFERENCE_COUNT = 0x18 # USHORT
PFN_E1 = 0x1A # USHORT MMPFNENTRY (PageLocation, PrototypePte, ...)
PFN_USED_PT_ENTRIES = 0x1C # ULONG (_WIN64)
PFN_ORIGINAL_PTE = 0x20 # MMPTE / RmapListHead
PFN_U4 = 0x28 # 8 bytes (PteFrame:57 | InPageError:1 | VerifierAlloc:1 | AweAlloc:1 | Priority:3 | MustBeCached:1)
SIZEOF_MMPFN = 0x60 # heuristic — covers u1..u4 + UsedPageTableEntries + Wsle/NextLRU/PreviousLRU
# We only ever index the array; sizeof is read from struct via gdb if available.
# CONTROL_AREA / SUBSECTION offsets (amd64)
CONTROL_AREA_SEGMENT = 0x00
CONTROL_AREA_NUM_PFN_REFS = 0x1C
CONTROL_AREA_NUM_VIEWS = 0x20
CONTROL_AREA_FLAGS = 0x2C
CONTROL_AREA_FILE_POINTER = 0x30
SIZEOF_CONTROL_AREA = 0x50 # SUBSECTION starts at ControlArea + 1
SUBSECTION_CONTROL_AREA = 0x00
SUBSECTION_FLAGS = 0x08
SUBSECTION_STARTING_SECTOR = 0x0C
SUBSECTION_NUM_FULL_SECTORS = 0x10
SUBSECTION_BASE = 0x18 # PMMPTE (proto array)
SUBSECTION_UNUSED_PTES = 0x20
SUBSECTION_PTES_IN = 0x24
SUBSECTION_NEXT = 0x28
def mi_address_to_pte(va):
"""MiAddressToPte (amd64): PTE_BASE + ((va >> 12) * 8) with the 9-bit
self-mapped masking from _MiAddressToPte in ntoskrnl/include/internal/amd64/mm.h."""
offset = (va >> (12 - 3)) & (0xFFFFFFFFF << 3)
return PTE_BASE_AMD64 + offset
def _decode_pte_value(val):
"""Return list of (form_name, dict_of_fields). The 'one' form the kernel
will interpret depends on Valid/Prototype/Transition triplet — we mark it
with 'active': True."""
valid = val & 1
prototype = (val >> 10) & 1
transition = (val >> 11) & 1
forms = []
# Hard.Valid form (only if Valid == 1)
hard = {
'PFN' : (val >> 12) & 0xFFFFFFFFF, # 36 bits at NTDDI_LONGHORN+
'Write' : (val >> 1) & 1, # Dirty1/Write
'Owner' : (val >> 2) & 1,
'WriteThrough': (val >> 3) & 1,
'CacheDisable': (val >> 4) & 1,
'Accessed' : (val >> 5) & 1,
'Dirty' : (val >> 6) & 1,
'LargePage' : (val >> 7) & 1,
'Global' : (val >> 8) & 1,
'CopyOnWrite' : (val >> 9) & 1,
'Prototype' : prototype,
'NoExecute' : (val >> 63) & 1,
}
forms.append(('Hard.Valid', hard, bool(valid)))
# Soft form (Valid=0, Prototype=0, Transition=0)
soft = {
'PageFileLow' : (val >> 1) & 0xF,
'Protection' : (val >> 5) & 0x1F,
'Prototype' : prototype,
'Transition' : transition,
'UsedPTEs' : (val >> 12) & 0x3FF,
'PageFileHigh': (val >> 32) & 0xFFFFFFFF,
}
soft_active = (not valid) and (not prototype) and (not transition)
forms.append(('Soft.PageFile', soft, soft_active))
# Trans form (Valid=0, Prototype=0, Transition=1)
trans = {
'PFN' : (val >> 12) & 0xFFFFFFFFF, # 36 bits at NTDDI_LONGHORN+
'Protection' : (val >> 5) & 0x1F,
'Write' : (val >> 1) & 1,
'Owner' : (val >> 2) & 1,
'WriteThrough': (val >> 3) & 1,
'CacheDisable': (val >> 4) & 1,
}
trans_active = (not valid) and (not prototype) and bool(transition)
forms.append(('Soft.Transition', trans, trans_active))
# Proto form (Valid=0, Prototype=1)
# ProtoAddress is signed 48-bit at bits 16..63
proto_raw = (val >> 16) & 0xFFFFFFFFFFFF
if proto_raw & (1 << 47):
proto_addr = proto_raw | 0xFFFF000000000000
else:
proto_addr = proto_raw
proto = {
'ProtoAddress': proto_addr,
'Protection' : (val >> 11) & 0x1F, # MMPTE_PROTOTYPE.Protection at bits 11..15
'ReadOnly' : (val >> 8) & 1,
}
proto_active = (not valid) and bool(prototype) and (not transition)
forms.append(('Soft.Prototype', proto, proto_active))
# Subsection form (Valid=0, Prototype=1) — same encoding as Proto but
# SubsectionAddress lives at bits 16..63 also; we annotate.
sub = {
'SubsectionAddress': proto_addr,
'Protection' : (val >> 5) & 0x1F,
}
forms.append(('Subsection', sub, False)) # never auto-active; same triplet as Proto
# List form (free PTE lists)
list_form = {
'OneEntry' : (val >> 1) & 1,
'NextEntry': (val >> 32) & 0xFFFFFFFF,
}
forms.append(('List', list_form, False))
return valid, prototype, transition, forms
def _classify_pte(val):
"""Return short tag of the *active* form."""
valid = val & 1
prototype = (val >> 10) & 1
transition = (val >> 11) & 1
if val == 0:
return 'ZERO'
if valid:
return 'HARD.VALID'
if prototype and not transition:
return 'PROTO'
if (not prototype) and transition:
return 'TRANSITION'
if (not prototype) and (not transition):
# Soft: distinguish "all-zero except protection" vs real pagefile entry
if ((val >> 32) & 0xFFFFFFFF) == 0 and ((val >> 1) & 0xF) == 0:
return 'SOFT.ZERO'
return 'SOFT.PAGEFILE'
return 'UNKNOWN'
def _print_pte(pte_addr, val, indent=' '):
valid, proto, trans, forms = _decode_pte_value(val)
tag = _classify_pte(val)
print(f"{indent}PTE @ 0x{pte_addr:x} = 0x{val:016x} [{tag}]")
print(f"{indent} Valid={valid} Prototype={proto} Transition={trans}")
for name, fields, active in forms:
if name in ('Subsection', 'List') and not active:
continue
if name == 'Hard.Valid' and not valid:
continue # don't spam Hard fields when not valid
marker = ' <== ACTIVE' if active else ''
kv = ' '.join(f"{k}=0x{v:x}" if isinstance(v, int) and v > 9 else f"{k}={v}"
for k, v in fields.items())
print(f"{indent} {name:18s} {kv}{marker}")
class ReactosPte(gdb.Command):
"""Decode a single 64-bit PTE at <pte_addr> (the address of the PTE cell).
Usage: ros-pte <pte_addr>"""
def __init__(self):
super().__init__("ros-pte", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-pte <pte_address>")
return
try:
pte_addr = int(arg.strip(), 0)
except Exception:
print(f"Bad address: {arg.strip()}")
return
val = read_u64(pte_addr)
if val is None:
print(f"Cannot read PTE at 0x{pte_addr:x}")
return
_print_pte(pte_addr, val, indent='')
ReactosPte()
class ReactosPteVa(gdb.Command):
"""Find PTE that maps <va> via PTE_BASE self-map and decode it.
Usage: ros-pte-va <va>"""
def __init__(self):
super().__init__("ros-pte-va", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-pte-va <virtual_address>")
return
try:
va = int(arg.strip(), 0)
except Exception:
print(f"Bad address: {arg.strip()}")
return
pte_addr = mi_address_to_pte(va)
print(f"VA 0x{va:x} -> PTE cell @ 0x{pte_addr:x}")
val = read_u64(pte_addr)
if val is None:
print(f" Cannot read PTE (PT not present?)")
return
_print_pte(pte_addr, val, indent=' ')
ReactosPteVa()
def _pfn_database_base():
"""Resolve MmPfnDatabase global. Falls back to canonical 0xFFFFFA8000000000."""
try:
addr = int(gdb.parse_and_eval("(unsigned long long)MmPfnDatabase"))
if addr:
return addr
except Exception:
pass
return 0xFFFFFA8000000000
def _pfn_entry_size():
"""Try to derive sizeof(MMPFN) via gdb type info; fall back to SIZEOF_MMPFN."""
try:
t = gdb.lookup_type("struct _MMPFN")
return int(t.sizeof)
except Exception:
return SIZEOF_MMPFN
def _pfn_addr_from_arg(s):
"""Accept either a raw PFN number (small) or an MMPFN struct address (large)."""
n = int(s, 0)
pfn_base = _pfn_database_base()
pfn_size = _pfn_entry_size()
if n < 0x100000000: # treat <4G as a PFN index
return pfn_base + n * pfn_size, n, pfn_size
# else treat as struct address
if n >= pfn_base and (n - pfn_base) % pfn_size == 0:
return n, (n - pfn_base) // pfn_size, pfn_size
# arbitrary address; return as-is, no PFN index
return n, None, pfn_size
PAGE_LOCATION_NAMES = {
0: 'Zeroed', 1: 'Free', 2: 'Standby', 3: 'Modified',
4: 'ModifiedNoWrite', 5: 'Bad', 6: 'Active', 7: 'Transition',
}
class ReactosPfn(gdb.Command):
"""Decode an MMPFN entry. Arg may be a PFN number or struct address.
Usage: ros-pfn <pfn_or_addr>"""
def __init__(self):
super().__init__("ros-pfn", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-pfn <pfn-number-or-address>")
return
try:
entry_addr, pfn_idx, pfn_size = _pfn_addr_from_arg(arg.strip())
except Exception as e:
print(f"Bad arg: {e}")
return
u1 = read_u64(entry_addr + PFN_U1)
pte = read_u64(entry_addr + PFN_PTE_ADDRESS)
u2 = read_u64(entry_addr + PFN_U2)
rcnt = read_u16(entry_addr + PFN_REFERENCE_COUNT)
e1 = read_u16(entry_addr + PFN_E1)
upte = read_u32(entry_addr + PFN_USED_PT_ENTRIES)
orig = read_u64(entry_addr + PFN_ORIGINAL_PTE)
u4 = read_u64(entry_addr + PFN_U4)
if u1 is None or pte is None:
print(f"Cannot read MMPFN at 0x{entry_addr:x}")
return
idx_str = f"PFN={pfn_idx} (0x{pfn_idx:x})" if pfn_idx is not None else "PFN=?"
print(f"=== MMPFN @ 0x{entry_addr:x} {idx_str} sizeof=0x{pfn_size:x} ===")
print(f" u1 = 0x{u1:016x} (Flink/WsIndex/Event/RmapListHead-HACK)")
print(f" PteAddress = 0x{pte:016x}")
print(f" u2 (ShareCount) = 0x{u2:016x} ({u2})")
print(f" ReferenceCount = {rcnt}")
if e1 is not None:
modified = e1 & 1
read_in_progress = (e1 >> 1) & 1
write_in_prog = (e1 >> 2) & 1
prototype_pte = (e1 >> 3) & 1
page_color = (e1 >> 4) & 0xF
page_location = (e1 >> 8) & 7
removal_req = (e1 >> 11) & 1
cache_attr = (e1 >> 12) & 3
rom = (e1 >> 14) & 1
parity = (e1 >> 15) & 1
loc_name = PAGE_LOCATION_NAMES.get(page_location, '?')
print(f" u3.e1 (0x{e1:04x}):")
print(f" PageLocation = {page_location} ({loc_name})")
print(f" PrototypePte = {prototype_pte}")
print(f" Modified = {modified}")
print(f" ReadInProgress = {read_in_progress} (StartOfAllocation)")
print(f" WriteInProgress = {write_in_prog} (EndOfAllocation)")
print(f" PageColor = {page_color}")
print(f" RemovalRequested = {removal_req}")
print(f" CacheAttribute = {cache_attr}")
print(f" Rom={rom} ParityError={parity}")
if upte is not None:
print(f" UsedPageTableEntries = {upte}")
if u4 is not None:
pte_frame = u4 & ((1 << 57) - 1)
in_page_err = (u4 >> 57) & 1
verifier = (u4 >> 58) & 1
awe = (u4 >> 59) & 1
priority = (u4 >> 60) & 7
must_cached = (u4 >> 63) & 1
print(f" u4 (0x{u4:016x}):")
print(f" PteFrame={pte_frame:#x} InPageError={in_page_err} VerifierAlloc={verifier} "
f"AweAlloc={awe} Priority={priority} MustBeCached={must_cached}")
if orig is not None:
print(f" OriginalPte:")
_print_pte(entry_addr + PFN_ORIGINAL_PTE, orig, indent=' ')
ReactosPfn()
# ros-rmap — walk the per-PFN rmap chain (ROS-managed MM)
#
# The rmap is stored at MMPFN.OriginalPte (overlaid as RmapListHead per
# ntoskrnl/include/internal/mm.h ~L430). Each MM_RMAP_ENTRY:
# +0x00 Next (PMM_RMAP_ENTRY)
# +0x08 Process (PEPROCESS)
# +0x10 Address (PVOID)
# size = 0x18.
def _eproc_name(eproc_addr):
"""Best-effort: scan EPROCESS bytes for first *.exe name."""
if not eproc_addr:
return ""
blk = read_mem(eproc_addr, 0x400)
if not blk:
return ""
hits = _re.findall(rb"[A-Za-z0-9_.\-]{2,20}\.exe", blk)
if hits:
try: return hits[0].decode("ascii")
except: return ""
return ""
class ReactosRmap(gdb.Command):
"""Walk the rmap (reverse-mapping) chain for a PFN. Each entry shows
which (Process, VA) currently has the page mapped via the ROS-managed
SSE-resident path.
Usage: ros-rmap <pfn-or-MMPFN-addr> [max=64]"""
def __init__(self):
super().__init__("ros-rmap", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
args = arg.strip().split()
if not args:
print("Usage: ros-rmap <pfn-or-addr> [max=64]")
return
try:
entry_addr, pfn_idx, _ = _pfn_addr_from_arg(args[0])
except Exception as e:
print(f"Bad arg: {e}")
return
max_n = int(args[1], 0) if len(args) > 1 else 64
head = read_u64(entry_addr + PFN_ORIGINAL_PTE)
if head is None:
print(f"Cannot read MMPFN at 0x{entry_addr:x}")
return
idx_str = f"PFN={pfn_idx} (0x{pfn_idx:x})" if pfn_idx is not None else "PFN=?"
print(f"=== rmap chain @ MMPFN 0x{entry_addr:x} {idx_str} ===")
print(f" RmapListHead = 0x{head:016x}")
if head == 0:
print(" (empty)")
return
seen = set()
cur = head
i = 0
while cur and cur not in seen and i < max_n:
seen.add(cur)
nxt = read_u64(cur + 0x00)
proc = read_u64(cur + 0x08)
va = read_u64(cur + 0x10)
if nxt is None or proc is None or va is None:
print(f" [{i:3d}] @0x{cur:016x} <unreadable>")
break
name = _eproc_name(proc) if proc else ""
tag = ""
# Special segment-rmap encoding: bit0 of Address indicates "segment rmap"
if va & 1:
tag = " [SEGMENT-RMAP]"
print(f" [{i:3d}] @0x{cur:016x} Process=0x{proc:016x} ({name}) Address=0x{va:016x}{tag} Next=0x{nxt:016x}")
cur = nxt
i += 1
if cur in seen:
print(f" *** loop detected at 0x{cur:x}")
elif i >= max_n:
print(f" ... truncated at {max_n} entries")
ReactosRmap()
class ReactosProtoWalk(gdb.Command):
"""Walk the proto-PTE array of a CONTROL_AREA's first SUBSECTION.
Flags any Hard.Valid proto as a MiSegmentDelete invariant violation.
Usage: ros-proto-walk <ControlArea_addr> [max_ptes=32]"""
def __init__(self):
super().__init__("ros-proto-walk", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
args = arg.strip().split()
if len(args) < 1:
print("Usage: ros-proto-walk <ControlArea_addr> [max_ptes=32]")
return
try:
ca = int(args[0], 0)
except Exception:
print(f"Bad ControlArea: {args[0]}")
return
max_ptes = int(args[1], 0) if len(args) >= 2 else 32
# Read CONTROL_AREA header
seg = read_u64(ca + CONTROL_AREA_SEGMENT)
npfn_refs = read_u32(ca + CONTROL_AREA_NUM_PFN_REFS)
nviews = read_u32(ca + CONTROL_AREA_NUM_VIEWS)
flags = read_u32(ca + CONTROL_AREA_FLAGS)
file_obj = read_u64(ca + CONTROL_AREA_FILE_POINTER)
if seg is None:
print(f"Cannot read CONTROL_AREA at 0x{ca:x}")
return
print(f"=== CONTROL_AREA @ 0x{ca:x} ===")
print(f" Segment = 0x{seg:x}")
print(f" NumberOfPfnRefs = {npfn_refs}")
print(f" NumberOfMappedViews= {nviews}")
print(f" Flags (LongFlags) = 0x{flags:x}")
print(f" FilePointer = 0x{file_obj:x}")
# First subsection lives at ControlArea + sizeof(CONTROL_AREA)
sub = ca + SIZEOF_CONTROL_AREA
sub_idx = 0
violations = 0
total_walked = 0
while sub and sub_idx < 16:
sub_ca = read_u64(sub + SUBSECTION_CONTROL_AREA)
sub_flags = read_u32(sub + SUBSECTION_FLAGS)
sub_base = read_u64(sub + SUBSECTION_BASE)
sub_unused = read_u32(sub + SUBSECTION_UNUSED_PTES)
sub_count = read_u32(sub + SUBSECTION_PTES_IN)
sub_next = read_u64(sub + SUBSECTION_NEXT)
if sub_base is None or sub_count is None:
print(f"\n Cannot read SUBSECTION at 0x{sub:x}")
break
if sub_ca != ca and sub_idx == 0:
# In ROS the embedded subsection's ControlArea backref must equal ca
print(f" WARN: SUBSECTION[0].ControlArea=0x{sub_ca:x} != 0x{ca:x}")
print(f"\n --- SUBSECTION[{sub_idx}] @ 0x{sub:x} ---")
print(f" ControlArea = 0x{sub_ca:x}")
print(f" SubsectionFlags = 0x{sub_flags:x}")
print(f" SubsectionBase = 0x{sub_base:x} (proto array)")
print(f" PtesInSubsection = {sub_count} UnusedPtes={sub_unused}")
print(f" NextSubsection = 0x{sub_next:x}")
limit = min(sub_count or 0, max_ptes)
print(f"\n Walking first {limit} of {sub_count} proto-PTEs:")
print(f" Invariant (MiSegmentDelete): each proto must be ZERO / TRANSITION / SOFT.PAGEFILE")
print(f" {'idx':>5s} {'addr':>18s} {'value':>18s} classification")
for i in range(limit):
pte_addr = sub_base + i * 8
v = read_u64(pte_addr)
if v is None:
print(f" {i:5d} 0x{pte_addr:016x} <unreadable>")
continue
tag = _classify_pte(v)
marker = ''
if tag == 'HARD.VALID':
marker = ' *** VIOLATION (proto must not be Hard.Valid)'
violations += 1
print(f" {i:5d} 0x{pte_addr:016x} 0x{v:016x} {tag}{marker}")
total_walked += 1
if not sub_next or sub_idx >= 0:
# Default: only walk first subsection unless caller asks for more.
# We still report NextSubsection above.
break
sub = sub_next
sub_idx += 1
print(f"\n Summary: walked {total_walked} proto-PTEs, {violations} VIOLATION(s)")
ReactosProtoWalk()
class ReactosMappingWalk(gdb.Command):
"""Walk PTEs for a contiguous VA range (e.g. an MmMapViewInSystemSpace view).
Usage: ros-mapping-walk <base_va> <num_pages>"""
def __init__(self):
super().__init__("ros-mapping-walk", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
args = arg.strip().split()
if len(args) < 2:
print("Usage: ros-mapping-walk <base_va> <num_pages>")
return
try:
base = int(args[0], 0) & ~0xFFF
n = int(args[1], 0)
except Exception:
print("Bad arguments")
return
if n <= 0 or n > 4096:
print(f"num_pages must be 1..4096 (got {n})")
return
print(f"VA range 0x{base:x} - 0x{base + n*0x1000 - 1:x} ({n} pages)")
print(f" {'idx':>5s} {'va':>18s} {'pte':>18s} {'value':>18s} class")
cnt_hard = cnt_proto = cnt_trans = cnt_soft = cnt_zero = cnt_unread = 0
for i in range(n):
va = base + i * 0x1000
pte_addr = mi_address_to_pte(va)
v = read_u64(pte_addr)
if v is None:
cnt_unread += 1
print(f" {i:5d} 0x{va:016x} 0x{pte_addr:016x} {'<unreadable>':>18s}")
continue
tag = _classify_pte(v)
if tag == 'HARD.VALID': cnt_hard += 1
elif tag == 'PROTO': cnt_proto += 1
elif tag == 'TRANSITION': cnt_trans += 1
elif tag.startswith('SOFT'): cnt_soft += 1
elif tag == 'ZERO': cnt_zero += 1
print(f" {i:5d} 0x{va:016x} 0x{pte_addr:016x} 0x{v:016x} {tag}")
print(f"\n Summary: hard={cnt_hard} proto={cnt_proto} transition={cnt_trans} "
f"soft={cnt_soft} zero={cnt_zero} unreadable={cnt_unread}")
ReactosMappingWalk()
# ============================================================
# MM struct dumpers — SECTION/SEGMENT/CONTROL_AREA/SUBSECTION,
# SECTION_OBJECT_POINTERS, SHARED_CACHE_MAP,
# ROS_VACB, MMVAD, MMSUPPORT, MM ranges.
# ============================================================
#
# Source-of-truth offsets (amd64, NTDDI_WIN8 — the active build target):
# sdk/include/ndk/mmtypes.h : _SECTION (l.809), _SEGMENT (l.407),
# _CONTROL_AREA (l.519), _SUBSECTION (l.571),
# _MMSECTION_FLAGS (l.461), _MMVAD (l.720),
# _MMVAD_SHORT (l.788), _MMVAD_FLAGS (l.686),
# _MM_AVL_TABLE (l.660), _MMSUPPORT (l.922)
# sdk/include/xdk/iotypes.h : _SECTION_OBJECT_POINTERS (l.1794)
# sdk/include/ndk/cctypes.h : _PRIVATE_CACHE_MAP (l.67)
# ntoskrnl/include/internal/cc.h: _ROS_SHARED_CACHE_MAP (l.170),
# _ROS_VACB (l.207)
# ntoskrnl/mm/ARM3/miarm.h : MmSystemRangeStart, Mm{Paged,NonPaged}PoolStart,
# MmSessionBase, MmSystemCache{Start,End,Ws},
# MiSystemViewStart
# sdk/include/ndk/pstypes.h : EPROCESS.VadRoot (l.1768) — last fields, runtime lookup
# --- _SECTION layout (amd64) ---
# MMADDRESS_NODE (5 ptrs/ULONG_PTR = 0x28) + Segment(8) + SizeOfSection(8) +
# u.LongFlags(4) + InitialPageProtection(4).
SECTION_SEGMENT = 0x28
SECTION_SIZEOF_SECTION = 0x30
SECTION_FLAGS = 0x38
SECTION_INITIAL_PROTECTION = 0x3C
# --- _SEGMENT layout (amd64) ---
# ControlArea(8) TotalNumberOfPtes(4) NonExtendedPtes(4) Spare0(4)
# pad(4) SizeOfSegment(8) SegmentPteTemplate(8) NumberOfCommittedPages(4)
# pad(4) ExtendInfo(8) SegmentFlags(4) pad(4) BasedAddress(8) u1(8) u2(8)
# PrototypePte(8) ThePtes[1](8)
SEGMENT_CONTROL_AREA = 0x00
SEGMENT_TOTAL_PTES = 0x08
SEGMENT_NON_EXTENDED_PTES = 0x0C
SEGMENT_SIZE_OF_SEGMENT = 0x18
SEGMENT_PTE_TEMPLATE = 0x20
SEGMENT_COMMITTED_PAGES = 0x28
SEGMENT_FLAGS_OFF = 0x38
SEGMENT_BASED_ADDRESS = 0x40
SEGMENT_PROTOTYPE_PTE = 0x60
# --- _MMSECTION_FLAGS bit table (sdk/include/ndk/mmtypes.h:461) ---
_MMSECTION_FLAG_BITS = [
'BeingDeleted','BeingCreated','BeingPurged','NoModifiedWriting',
'FailAllIo','Image','Based','File','Networked','NoCache',
'PhysicalMemory','CopyOnWrite','Reserve','Commit','FloppyMedia',
'WasPurged','UserReference','GlobalMemory','DeleteOnClose',
'FilePointerNull','DebugSymbolsLoaded','SetMappedFileIoComplete',
'CollidedFlush','NoChange','filler0','ImageMappedInSystemSpace',
'UserWritable','Accessed','GlobalOnlyPerSession','Rom',
'WriteCombined','filler',
]
def _decode_section_flags(val):
if val is None:
return '<unreadable>'
set_bits = [name for i, name in enumerate(_MMSECTION_FLAG_BITS)
if (val >> i) & 1 and not name.startswith('filler')]
return ', '.join(set_bits) if set_bits else '(none)'
# --- _CONTROL_AREA full layout (amd64, NT62) ---
# 0x00 Segment, 0x08 DereferenceList(16), 0x18 NumberOfSectionReferences(4),
# 0x1C NumberOfPfnReferences(4), 0x20 NumberOfMappedViews(4),
# 0x24 NumberOfSystemCacheViews(4), 0x28 NumberOfUserReferences(4),
# 0x2C u.LongFlags(4), 0x30 FilePointer(8), 0x38 WaitingForDeletion(8),
# 0x40 ModifiedWriteCount(2), 0x42 FlushInProgressCount(2),
# 0x44 WritableUserReferences(4), 0x48 QuadwordPad(4) [+pad to 0x50].
CA_SEGMENT = 0x00
CA_DEREF_LIST = 0x08
CA_NUM_SEC_REFS = 0x18
CA_NUM_PFN_REFS = 0x1C
CA_NUM_MAPPED = 0x20
CA_NUM_SYS_CACHE = 0x24
CA_NUM_USER_REFS = 0x28
CA_LONG_FLAGS = 0x2C
CA_FILE_POINTER = 0x30
CA_WAITING_DEL = 0x38
CA_MODIFIED_WC = 0x40
CA_FLUSH_IP = 0x42
CA_WRITABLE_UREFS = 0x44
# --- _SECTION_OBJECT_POINTERS ---
SOP_DATA_SECTION = 0x00
SOP_SHARED_CACHE = 0x08
SOP_IMAGE_SECTION = 0x10
# --- _ROS_SHARED_CACHE_MAP layout (amd64, computed from cc.h:170) ---
# NodeTypeCode(2)+NodeByteSize(2)+OpenCount(4)=8;
# FileSize@0x08(8); BcbList@0x10(16); SectionSize@0x20(8);
# ValidDataLength@0x28(8); FileObject@0x30(8); DirtyPages@0x38(4);
# SharedCacheMapLinks@0x40(16); Flags@0x50(4); pad(4); Section@0x58(8);
# CreateEvent@0x60(8); Callbacks@0x68(8); LazyWriteContext@0x70(8);
# PrivateList@0x78(16); DirtyPageThreshold@0x88(4); pad(4);
# BcbSpinLock@0x90(8); PrivateCacheMap@0x98(0x68);
# CacheMapVacbListHead@0x100(16); ...
SCM_NODE_TYPE = 0x00
SCM_NODE_BYTE_SIZE = 0x02
SCM_OPEN_COUNT = 0x04
SCM_FILE_SIZE = 0x08
SCM_BCB_LIST = 0x10
SCM_SECTION_SIZE = 0x20
SCM_VALID_DATA_LEN = 0x28
SCM_FILE_OBJECT = 0x30
SCM_DIRTY_PAGES = 0x38
SCM_LINKS = 0x40
SCM_FLAGS = 0x50
SCM_SECTION = 0x58
SCM_CREATE_EVENT = 0x60
SCM_CALLBACKS = 0x68
SCM_LAZY_WRITE_CTX = 0x70
SCM_PRIVATE_LIST = 0x78
SCM_DIRTY_THRESHOLD = 0x88
SCM_BCB_SPINLOCK = 0x90
SCM_PRIVATE_CACHEMAP = 0x98
SCM_VACB_LIST_HEAD = 0x100
# --- _ROS_VACB layout (amd64, computed from cc.h:207) ---
# BaseAddress(8)@0; Dirty(1)@8; PageOut(1)@9; pad(2); MappedCount(4)@0xC;
# CacheMapVacbListEntry@0x10(16); DirtyVacbListEntry@0x20(16);
# VacbLruListEntry@0x30(16); FileOffset@0x40(8); ReferenceCount@0x48(4);
# pad(4); SharedCacheMap@0x50(8).
VACB_BASE_ADDRESS = 0x00
VACB_DIRTY = 0x08
VACB_PAGE_OUT = 0x09
VACB_MAPPED_COUNT = 0x0C
VACB_LIST_ENTRY = 0x10 # CacheMapVacbListEntry (Flink)
VACB_DIRTY_LIST_ENTRY = 0x20
VACB_LRU_LIST_ENTRY = 0x30
VACB_FILE_OFFSET = 0x40
VACB_REFCOUNT = 0x48
VACB_SHARED_CACHE_MAP = 0x50
# --- _MMVAD_SHORT layout (amd64, mmtypes.h:788) ---
# u1(Parent or Balance)(8)@0; LeftChild(8)@8; RightChild(8)@0x10;
# StartingVpn(8)@0x18; EndingVpn(8)@0x20; u.LongFlags(8)@0x28.
VAD_PARENT = 0x00
VAD_LEFT = 0x08
VAD_RIGHT = 0x10
VAD_STARTING_VPN = 0x18
VAD_ENDING_VPN = 0x20
VAD_LONG_FLAGS = 0x28
# Long _MMVAD also has at +0x30 ControlArea, +0x38 FirstPrototypePte,
# +0x40 LastContiguousPte, +0x48 LongFlags2.
VAD_CONTROL_AREA = 0x30
VAD_FIRST_PROTO_PTE = 0x38
VAD_LAST_CONTIG_PTE = 0x40
VAD_TYPE_NAMES = {
0: 'VadNone', 1: 'VadDevicePhysicalMemory', 2: 'VadImageMap',
3: 'VadAwe', 4: 'VadWriteWatch', 5: 'VadLargePages',
6: 'VadRotatePhysical', 7: 'VadLargePageSection',
}
def _decode_mmvad_flags(longflags):
"""Decode MMVAD_FLAGS (amd64). See mmtypes.h:686.
CommitCharge:51 NoChange:1 VadType:3 MemCommit:1 Protection:5 Spare:2 PrivateMemory:1"""
if longflags is None:
return {}
return {
'CommitCharge' : longflags & ((1 << 51) - 1),
'NoChange' : (longflags >> 51) & 1,
'VadType' : (longflags >> 52) & 7,
'MemCommit' : (longflags >> 55) & 1,
'Protection' : (longflags >> 56) & 0x1F,
'PrivateMemory' : (longflags >> 63) & 1,
}
def _try_eval_addr(expr):
"""Resolve a kernel symbol or expression to an integer address; None on failure."""
try:
return int(gdb.parse_and_eval(expr)) & 0xFFFFFFFFFFFFFFFF
except Exception:
return None
def _maybe_lookup_field_offset(typename, fieldname):
"""Return offset of fieldname in typename via gdb type info; None if unavailable."""
try:
t = gdb.lookup_type(typename)
for f in t.fields():
if f.name == fieldname:
return f.bitpos // 8
except Exception:
pass
return None
def _read_unicode_string_at(addr):
"""UNICODE_STRING { USHORT Length; USHORT MaxLen; ULONG pad; PWSTR Buffer; }"""
if not addr:
return None
return read_unicode_string(addr)
# ============================================================
# ros-section <addr>
# ============================================================
class ReactosSection(gdb.Command):
"""Dump _SECTION (the user object). Auto-chains into Segment.
Usage: ros-section <addr>"""
def __init__(self):
super().__init__("ros-section", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-section <SECTION_addr>")
return
try:
addr = int(arg.strip(), 0)
except Exception:
print(f"Bad address: {arg.strip()}")
return
try:
seg = read_u64(addr + SECTION_SEGMENT)
size = read_u64(addr + SECTION_SIZEOF_SECTION)
flags = read_u32(addr + SECTION_FLAGS)
init_protect = read_u32(addr + SECTION_INITIAL_PROTECTION)
if seg is None:
print(f"Cannot read SECTION at 0x{addr:x}")
return
print(f"=== Section @ 0x{addr:016x} ===")
print(f" Segment = 0x{seg:016x}")
if size is not None:
print(f" SizeOfSection = 0x{size:x} ({size} bytes)")
print(f" InitialPageProtection = 0x{init_protect:x}")
print(f" u.LongFlags = 0x{flags:x}")
print(f" flags set : {_decode_section_flags(flags)}")
print(f" Use: ros-segment 0x{seg:x}")
except Exception as e:
print(f"ros-section error: {e}")
ReactosSection()
# ============================================================
# ros-segment <addr>
# ============================================================
class ReactosSegment(gdb.Command):
"""Dump _SEGMENT.
Usage: ros-segment <addr>"""
def __init__(self):
super().__init__("ros-segment", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-segment <SEGMENT_addr>")
return
try:
addr = int(arg.strip(), 0)
except Exception:
print(f"Bad address: {arg.strip()}")
return
try:
ca = read_u64(addr + SEGMENT_CONTROL_AREA)
total_ptes = read_u32(addr + SEGMENT_TOTAL_PTES)
non_ext_ptes = read_u32(addr + SEGMENT_NON_EXTENDED_PTES)
seg_size = read_u64(addr + SEGMENT_SIZE_OF_SEGMENT)
pte_template = read_u64(addr + SEGMENT_PTE_TEMPLATE)
committed = read_u32(addr + SEGMENT_COMMITTED_PAGES)
seg_flags = read_u32(addr + SEGMENT_FLAGS_OFF)
based_addr = read_u64(addr + SEGMENT_BASED_ADDRESS)
proto_pte = read_u64(addr + SEGMENT_PROTOTYPE_PTE)
if ca is None:
print(f"Cannot read SEGMENT at 0x{addr:x}")
return
print(f"=== Segment @ 0x{addr:016x} ===")
print(f" ControlArea = 0x{ca:016x}")
print(f" TotalNumberOfPtes = {total_ptes}")
print(f" NonExtendedPtes = {non_ext_ptes}")
print(f" SizeOfSegment = 0x{seg_size:x} ({seg_size} bytes)")
print(f" SegmentPteTemplate = 0x{pte_template:016x}")
_print_pte(addr + SEGMENT_PTE_TEMPLATE, pte_template, indent=' ')
print(f" NumberOfCommittedPages= {committed}")
print(f" SegmentFlags = 0x{seg_flags:x}")
print(f" BasedAddress = 0x{based_addr:016x}")
print(f" PrototypePte = 0x{proto_pte:016x} (head of proto array)")
print(f" Use: ros-controlarea 0x{ca:x}")
print(f" ros-proto-walk 0x{ca:x}")
except Exception as e:
print(f"ros-segment error: {e}")
ReactosSegment()
# ============================================================
# ros-controlarea <addr>
# ============================================================
class ReactosControlArea(gdb.Command):
"""Dump _CONTROL_AREA. Auto-chains into the embedded SUBSECTION.
Usage: ros-controlarea <addr>"""
def __init__(self):
super().__init__("ros-controlarea", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-controlarea <CONTROL_AREA_addr>")
return
try:
ca = int(arg.strip(), 0)
except Exception:
print(f"Bad address: {arg.strip()}")
return
try:
seg = read_u64(ca + CA_SEGMENT)
n_sec_refs = read_u32(ca + CA_NUM_SEC_REFS)
n_pfn_refs = read_u32(ca + CA_NUM_PFN_REFS)
n_mapped = read_u32(ca + CA_NUM_MAPPED)
n_sys_cache = read_u32(ca + CA_NUM_SYS_CACHE)
n_user_refs = read_u32(ca + CA_NUM_USER_REFS)
flags = read_u32(ca + CA_LONG_FLAGS)
file_obj = read_u64(ca + CA_FILE_POINTER)
waiting_del = read_u64(ca + CA_WAITING_DEL)
mod_wc = read_u16(ca + CA_MODIFIED_WC)
flush_ip = read_u16(ca + CA_FLUSH_IP)
writable_ur = read_u32(ca + CA_WRITABLE_UREFS)
if seg is None:
print(f"Cannot read CONTROL_AREA at 0x{ca:x}")
return
print(f"=== ControlArea @ 0x{ca:016x} ===")
print(f" Segment = 0x{seg:016x}")
print(f" NumberOfSectionReferences= {n_sec_refs}")
print(f" NumberOfPfnReferences = {n_pfn_refs}")
print(f" NumberOfMappedViews = {n_mapped}")
print(f" NumberOfSystemCacheViews = {n_sys_cache}")
print(f" NumberOfUserReferences = {n_user_refs}")
print(f" WritableUserReferences = {writable_ur}")
print(f" ModifiedWriteCount = {mod_wc}")
print(f" FlushInProgressCount = {flush_ip}")
print(f" WaitingForDeletion = 0x{waiting_del:016x}")
print(f" FilePointer = 0x{file_obj:016x}")
# Try to peek at FILE_OBJECT.FileName (offset 0x58 amd64) if non-NULL
if file_obj:
try:
fname_off = _maybe_lookup_field_offset("struct _FILE_OBJECT",
"FileName")
if fname_off is None:
fname_off = 0x58 # amd64 default
name = _read_unicode_string_at(file_obj + fname_off)
if name:
print(f" FilePointer->FileName = \"{name}\"")
read_off = _maybe_lookup_field_offset("struct _FILE_OBJECT",
"ReadAccess")
if read_off is not None:
ra = read_u8(file_obj + read_off)
wa = read_u8(file_obj + read_off + 1)
if ra is not None:
print(f" FilePointer->ReadAccess={ra} WriteAccess={wa}")
except Exception:
pass
print(f" u.LongFlags = 0x{flags:x}")
print(f" flags set : {_decode_section_flags(flags)}")
sub = ca + 0x50 # SIZEOF_CONTROL_AREA — first SUBSECTION immediately follows
print(f" embedded SUBSECTION at = 0x{sub:016x}")
print(f" Use: ros-subsection 0x{sub:x}")
print(f" ros-segment 0x{seg:x}")
print(f" ros-proto-walk 0x{ca:x}")
except Exception as e:
print(f"ros-controlarea error: {e}")
ReactosControlArea()
# ============================================================
# ros-subsection <addr>
# ============================================================
class ReactosSubsection(gdb.Command):
"""Dump _SUBSECTION (mmtypes.h:571).
Usage: ros-subsection <addr>"""
def __init__(self):
super().__init__("ros-subsection", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-subsection <SUBSECTION_addr>")
return
try:
sub = int(arg.strip(), 0)
except Exception:
print(f"Bad address: {arg.strip()}")
return
try:
ca = read_u64(sub + SUBSECTION_CONTROL_AREA)
sub_flags = read_u32(sub + SUBSECTION_FLAGS)
start_sec = read_u32(sub + SUBSECTION_STARTING_SECTOR)
full_secs = read_u32(sub + SUBSECTION_NUM_FULL_SECTORS)
sub_base = read_u64(sub + SUBSECTION_BASE)
unused = read_u32(sub + SUBSECTION_UNUSED_PTES)
count = read_u32(sub + SUBSECTION_PTES_IN)
nxt = read_u64(sub + SUBSECTION_NEXT)
if ca is None:
print(f"Cannot read SUBSECTION at 0x{sub:x}")
return
print(f"=== Subsection @ 0x{sub:016x} ===")
print(f" ControlArea = 0x{ca:016x}")
print(f" NextSubsection = 0x{nxt:016x}")
print(f" SubsectionBase = 0x{sub_base:016x} (proto-PTE array)")
print(f" PtesInSubsection = {count}")
print(f" UnusedPtes = {unused}")
print(f" StartingSector = {start_sec}")
print(f" NumberOfFullSectors = {full_secs}")
print(f" SubsectionFlags = 0x{sub_flags:x}")
# Decode MMSUBSECTION_FLAGS bits (mmtypes.h:497)
ro = sub_flags & 1
rw = (sub_flags >> 1) & 1
stat = (sub_flags >> 2) & 1
gmem = (sub_flags >> 3) & 1
prot = (sub_flags >> 4) & 0x1F
print(f" ReadOnly={ro} ReadWrite={rw} Static={stat} GlobalMemory={gmem}"
f" Protection=0x{prot:x}")
print(f" Use: ros-proto-walk 0x{ca:x} {count}")
print(f" ros-controlarea 0x{ca:x}")
except Exception as e:
print(f"ros-subsection error: {e}")
ReactosSubsection()
# ============================================================
# ros-secptr <addr>
# ============================================================
class ReactosSecPtr(gdb.Command):
"""Dump _SECTION_OBJECT_POINTERS (xdk/iotypes.h:1794).
Auto-chains into ros-cachemap if SharedCacheMap is non-NULL.
Usage: ros-secptr <addr>"""
def __init__(self):
super().__init__("ros-secptr", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-secptr <SECTION_OBJECT_POINTERS_addr>")
return
try:
sop = int(arg.strip(), 0)
except Exception:
print(f"Bad address: {arg.strip()}")
return
try:
data_sect = read_u64(sop + SOP_DATA_SECTION)
shared_cm = read_u64(sop + SOP_SHARED_CACHE)
image_sect = read_u64(sop + SOP_IMAGE_SECTION)
if data_sect is None:
print(f"Cannot read SECTION_OBJECT_POINTERS at 0x{sop:x}")
return
print(f"=== SectionObjectPointers @ 0x{sop:016x} ===")
print(f" DataSectionObject = 0x{data_sect:016x} (CONTROL_AREA*)")
print(f" SharedCacheMap = 0x{shared_cm:016x} (ROS_SHARED_CACHE_MAP*)")
print(f" ImageSectionObject = 0x{image_sect:016x} (CONTROL_AREA*)")
if data_sect:
print(f" Use: ros-controlarea 0x{data_sect:x}")
if image_sect and image_sect != data_sect:
print(f" ros-controlarea 0x{image_sect:x}")
if shared_cm:
print(f" ros-cachemap 0x{shared_cm:x}")
except Exception as e:
print(f"ros-secptr error: {e}")
ReactosSecPtr()
# ============================================================
# ros-cachemap <addr>
# ============================================================
class ReactosCacheMap(gdb.Command):
"""Dump _ROS_SHARED_CACHE_MAP (cc.h:170).
Usage: ros-cachemap <addr>"""
def __init__(self):
super().__init__("ros-cachemap", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-cachemap <ROS_SHARED_CACHE_MAP_addr>")
return
try:
scm = int(arg.strip(), 0)
except Exception:
print(f"Bad address: {arg.strip()}")
return
try:
ntype = read_u16(scm + SCM_NODE_TYPE)
nbsize = read_u16(scm + SCM_NODE_BYTE_SIZE)
opencount = read_u32(scm + SCM_OPEN_COUNT)
file_size = read_u64(scm + SCM_FILE_SIZE)
sect_size = read_u64(scm + SCM_SECTION_SIZE)
valid_dl = read_u64(scm + SCM_VALID_DATA_LEN)
file_obj = read_u64(scm + SCM_FILE_OBJECT)
dirty = read_u32(scm + SCM_DIRTY_PAGES)
flags = read_u32(scm + SCM_FLAGS)
section = read_u64(scm + SCM_SECTION)
bcb_lock = read_u64(scm + SCM_BCB_SPINLOCK)
vacb_flink= read_u64(scm + SCM_VACB_LIST_HEAD)
vacb_blink= read_u64(scm + SCM_VACB_LIST_HEAD + 8)
link_f = read_u64(scm + SCM_LINKS)
link_b = read_u64(scm + SCM_LINKS + 8)
if file_obj is None:
print(f"Cannot read ROS_SHARED_CACHE_MAP at 0x{scm:x}")
return
print(f"=== SharedCacheMap @ 0x{scm:016x} ===")
print(f" NodeTypeCode/ByteSize = 0x{ntype:04x} / 0x{nbsize:04x}")
print(f" OpenCount = {opencount}")
print(f" FileSize = 0x{file_size:x}")
print(f" SectionSize = 0x{sect_size:x}")
print(f" ValidDataLength = 0x{valid_dl:x}")
print(f" FileObject = 0x{file_obj:016x}")
if file_obj:
try:
fname_off = _maybe_lookup_field_offset("struct _FILE_OBJECT",
"FileName") or 0x58
name = _read_unicode_string_at(file_obj + fname_off)
if name:
print(f" FileObject->FileName = \"{name}\"")
except Exception:
pass
print(f" DirtyPages = {dirty}")
print(f" Flags = 0x{flags:x}")
fbits = []
if flags & 0x1: fbits.append('READAHEAD_DISABLED')
if flags & 0x2: fbits.append('WRITEBEHIND_DISABLED')
if flags & 0x4: fbits.append('IN_CREATION')
if flags & 0x8: fbits.append('IN_LAZYWRITE')
if fbits:
print(f" flags set : {', '.join(fbits)}")
print(f" Section = 0x{section:016x}")
print(f" BcbSpinLock = 0x{bcb_lock:x}")
print(f" CleanSharedCacheMapLink= F=0x{link_f:x} B=0x{link_b:x}")
print(f" CacheMapVacbListHead = F=0x{vacb_flink:016x} B=0x{vacb_blink:016x}")
empty = (vacb_flink == scm + SCM_VACB_LIST_HEAD)
if empty:
print(f" (VACB list is empty — list head points to itself)")
else:
print(f" Use: ros-vacb-list 0x{scm:x}")
print(f" ros-section 0x{section:x}")
except Exception as e:
print(f"ros-cachemap error: {e}")
ReactosCacheMap()
# ============================================================
# ros-vacb <addr>
# ============================================================
class ReactosVacb(gdb.Command):
"""Dump one _ROS_VACB (cc.h:207).
Usage: ros-vacb <addr>"""
def __init__(self):
super().__init__("ros-vacb", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-vacb <ROS_VACB_addr>")
return
try:
v = int(arg.strip(), 0)
except Exception:
print(f"Bad address: {arg.strip()}")
return
try:
base = read_u64(v + VACB_BASE_ADDRESS)
dirty = read_u8(v + VACB_DIRTY)
pageout = read_u8(v + VACB_PAGE_OUT)
mapped = read_u32(v + VACB_MAPPED_COUNT)
le_f = read_u64(v + VACB_LIST_ENTRY)
le_b = read_u64(v + VACB_LIST_ENTRY + 8)
foffset = read_u64(v + VACB_FILE_OFFSET)
refcnt = read_u32(v + VACB_REFCOUNT)
scm = read_u64(v + VACB_SHARED_CACHE_MAP)
if base is None:
print(f"Cannot read ROS_VACB at 0x{v:x}")
return
print(f"=== Vacb @ 0x{v:016x} ===")
print(f" BaseAddress = 0x{base:016x}")
print(f" FileOffset = 0x{foffset:x}")
print(f" ReferenceCount = {refcnt}")
print(f" MappedCount = {mapped}")
print(f" Dirty = {dirty}")
print(f" PageOut = {pageout}")
print(f" SharedCacheMap = 0x{scm:016x}")
print(f" CacheMapVacbListEntry = F=0x{le_f:x} B=0x{le_b:x}")
if scm:
print(f" Use: ros-cachemap 0x{scm:x}")
print(f" ros-mapping-walk 0x{base:x} <num_pages>")
except Exception as e:
print(f"ros-vacb error: {e}")
ReactosVacb()
# ============================================================
# ros-vacb-list <SharedCacheMap_addr> [max=16]
# ============================================================
class ReactosVacbList(gdb.Command):
"""Walk CacheMapVacbListHead embedded in a ROS_SHARED_CACHE_MAP.
Usage: ros-vacb-list <SharedCacheMap_addr> [max=16]"""
def __init__(self):
super().__init__("ros-vacb-list", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
args = arg.strip().split()
if len(args) < 1:
print("Usage: ros-vacb-list <SharedCacheMap_addr> [max=16]")
return
try:
scm = int(args[0], 0)
except Exception:
print(f"Bad address: {args[0]}")
return
max_walk = int(args[1], 0) if len(args) >= 2 else 16
head = scm + SCM_VACB_LIST_HEAD
try:
cur = read_u64(head)
if cur is None:
print(f"Cannot read list head at 0x{head:x}")
return
print(f"=== VACB list of SharedCacheMap @ 0x{scm:016x} ===")
print(f" ListHead @ 0x{head:016x} -> Flink=0x{cur:x}")
print(f" {'#':>3s} {'vacb':>18s} {'base':>18s} {'fileoff':>14s} "
f"{'ref':>4s} {'map':>4s} {'D':>1s} {'PO':>2s}")
i = 0
visited = set()
while cur and cur != head and i < max_walk:
if cur in visited:
print(f" ** loop detected at 0x{cur:x}")
break
visited.add(cur)
# cur points at the embedded LIST_ENTRY (CacheMapVacbListEntry)
vacb = cur - VACB_LIST_ENTRY
base = read_u64(vacb + VACB_BASE_ADDRESS)
foff = read_u64(vacb + VACB_FILE_OFFSET)
refcnt = read_u32(vacb + VACB_REFCOUNT)
mapped = read_u32(vacb + VACB_MAPPED_COUNT)
dirty = read_u8(vacb + VACB_DIRTY)
pageout = read_u8(vacb + VACB_PAGE_OUT)
if base is None:
print(f" {i:3d} 0x{vacb:016x} <unreadable>")
break
print(f" {i:3d} 0x{vacb:016x} 0x{base:016x} 0x{foff:>12x} "
f"{refcnt:>4d} {mapped:>4d} {dirty:>1d} {pageout:>2d}")
nxt = read_u64(cur)
if nxt is None:
break
cur = nxt
i += 1
if i == 0 and cur == head:
print(" (list is empty)")
elif i >= max_walk:
print(f" ... truncated at {max_walk} entries")
except Exception as e:
print(f"ros-vacb-list error: {e}")
ReactosVacbList()
# ============================================================
# ros-vad <addr>
# ============================================================
class ReactosVad(gdb.Command):
"""Dump _MMVAD_SHORT and (if applicable) _MMVAD long-form fields.
Usage: ros-vad <vad_addr>"""
def __init__(self):
super().__init__("ros-vad", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-vad <vad_addr>")
return
try:
v = int(arg.strip(), 0)
except Exception:
print(f"Bad address: {arg.strip()}")
return
try:
parent_raw = read_u64(v + VAD_PARENT)
left = read_u64(v + VAD_LEFT)
right = read_u64(v + VAD_RIGHT)
start_vpn = read_u64(v + VAD_STARTING_VPN)
end_vpn = read_u64(v + VAD_ENDING_VPN)
longflags = read_u64(v + VAD_LONG_FLAGS)
if parent_raw is None:
print(f"Cannot read VAD at 0x{v:x}")
return
balance = parent_raw & 3
parent = parent_raw & ~3
flags = _decode_mmvad_flags(longflags)
vt_name = VAD_TYPE_NAMES.get(flags.get('VadType'), '?')
va_lo = (start_vpn or 0) << 12
va_hi = (((end_vpn or 0) + 1) << 12) - 1
print(f"=== Vad @ 0x{v:016x} ===")
print(f" Parent (u1) = 0x{parent:016x} Balance={balance}")
print(f" LeftChild = 0x{left:016x}")
print(f" RightChild = 0x{right:016x}")
print(f" StartingVpn = 0x{start_vpn:x} EndingVpn=0x{end_vpn:x}")
print(f" VA range = 0x{va_lo:x} .. 0x{va_hi:x}")
print(f" u.LongFlags = 0x{longflags:x}")
print(f" CommitCharge = 0x{flags['CommitCharge']:x}")
print(f" Protection = 0x{flags['Protection']:x}")
print(f" VadType = {flags['VadType']} ({vt_name})")
print(f" NoChange = {flags['NoChange']}")
print(f" MemCommit = {flags['MemCommit']}")
print(f" PrivateMemory = {flags['PrivateMemory']}")
# If not PrivateMemory, this is a long VAD with ControlArea + protos.
if flags['PrivateMemory'] == 0:
ca = read_u64(v + VAD_CONTROL_AREA)
first_proto= read_u64(v + VAD_FIRST_PROTO_PTE)
last_contig= read_u64(v + VAD_LAST_CONTIG_PTE)
if ca is not None:
print(f" ControlArea = 0x{ca:016x}")
print(f" FirstPrototypePte = 0x{first_proto:016x}")
print(f" LastContiguousPte = 0x{last_contig:016x}")
if ca:
print(f" Use: ros-controlarea 0x{ca:x}")
except Exception as e:
print(f"ros-vad error: {e}")
ReactosVad()
# ============================================================
# ros-vad-tree <eprocess_or_avltable_addr> [max=64]
# ============================================================
def _walk_vad_tree(node, out, depth, max_nodes):
if not node or len(out) >= max_nodes:
return
if depth > 64:
return
out.append(node)
left = read_u64(node + VAD_LEFT)
right = read_u64(node + VAD_RIGHT)
if left:
_walk_vad_tree(left, out, depth + 1, max_nodes)
if right:
_walk_vad_tree(right, out, depth + 1, max_nodes)
class ReactosVadTree(gdb.Command):
"""Walk an EPROCESS's VAD AVL tree (or any _MM_AVL_TABLE root).
Usage: ros-vad-tree <eprocess_or_table_addr> [max=64]"""
def __init__(self):
super().__init__("ros-vad-tree", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
args = arg.strip().split()
if len(args) < 1:
print("Usage: ros-vad-tree <eprocess_or_table_addr> [max=64]")
return
try:
target = int(args[0], 0)
except Exception:
print(f"Bad address: {args[0]}")
return
max_nodes = int(args[1], 0) if len(args) >= 2 else 64
try:
# Try to interpret as EPROCESS by computing VadRoot offset via gdb types.
vad_root_off = _maybe_lookup_field_offset("struct _EPROCESS", "VadRoot")
avl = None
if vad_root_off is not None:
avl = target + vad_root_off
print(f" Treating 0x{target:x} as EPROCESS, VadRoot @ +0x{vad_root_off:x}"
f" -> 0x{avl:x}")
else:
avl = target
print(f" Treating 0x{target:x} as raw _MM_AVL_TABLE")
# _MM_AVL_TABLE.BalancedRoot is a _MMADDRESS_NODE; the actual tree root
# is BalancedRoot.RightChild (per ARM3 convention).
balanced_right = read_u64(avl + 0x10) # MMADDRESS_NODE.RightChild
if balanced_right is None:
print(f" Cannot read BalancedRoot at 0x{avl:x}")
return
if not balanced_right:
print(f" Tree is empty (BalancedRoot.RightChild == NULL)")
return
print(f" BalancedRoot.RightChild = 0x{balanced_right:x}")
nodes = []
_walk_vad_tree(balanced_right, nodes, 0, max_nodes)
print(f" walked {len(nodes)} VAD node(s):")
print(f" {'idx':>4s} {'vad':>18s} {'va_start':>18s} "
f"{'va_end':>18s} type protect")
for i, n in enumerate(nodes):
sv = read_u64(n + VAD_STARTING_VPN) or 0
ev = read_u64(n + VAD_ENDING_VPN) or 0
lf = read_u64(n + VAD_LONG_FLAGS) or 0
fl = _decode_mmvad_flags(lf)
print(f" {i:4d} 0x{n:016x} 0x{sv<<12:016x} "
f"0x{((ev+1)<<12)-1:016x} "
f"{fl['VadType']} 0x{fl['Protection']:x}")
except Exception as e:
print(f"ros-vad-tree error: {e}")
ReactosVadTree()
# ============================================================
# ros-mmsupport <process_addr>
# ============================================================
class ReactosMmSupport(gdb.Command):
"""Dump _MMSUPPORT (mmtypes.h:922).
Pass a pointer directly to MMSUPPORT, or an EPROCESS (we'll find Vm via gdb types).
Usage: ros-mmsupport <addr>"""
def __init__(self):
super().__init__("ros-mmsupport", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-mmsupport <addr>")
return
try:
target = int(arg.strip(), 0)
except Exception:
print(f"Bad address: {arg.strip()}")
return
try:
# If it looks like an EPROCESS, locate the Vm field.
vm_off = _maybe_lookup_field_offset("struct _EPROCESS", "Vm")
mms = target
if vm_off is not None:
# Heuristic: if &EPROCESS->Vm is readable as MMSUPPORT, prefer it.
mms = target + vm_off
print(f" Treating 0x{target:x} as EPROCESS, Vm @ +0x{vm_off:x}"
f" -> 0x{mms:x}")
# NTDDI_LONGHORN+ layout. WorkingSetExpansionLinks(16) + LastTrimpStamp(2)
# + NextPageColor(2) + Flags(4) + PageFaultCount(4) + PeakWorkingSetSize(4)
# + Spare0(4) + MinimumWorkingSetSize(4) + MaximumWorkingSetSize(4)
# = 0x10 + 4 + 4 + 4 + 4 + 4 + 4 = 0x28. Then VmWorkingSetList(8) @ 0x28.
# Then Claim(4)@0x30, Spare(4)@0x34, WorkingSetPrivateSize(4)@0x38,
# WorkingSetSizeOverhead(4)@0x3C, WorkingSetSize(4)@0x40.
wsel_f = read_u64(mms + 0x00)
wsel_b = read_u64(mms + 0x08)
flags = read_u32(mms + 0x14)
pfcount = read_u32(mms + 0x18)
peak_ws = read_u32(mms + 0x1C)
min_ws = read_u32(mms + 0x24)
max_ws = read_u32(mms + 0x28)
wsl = read_u64(mms + 0x30)
ws_size = read_u32(mms + 0x48)
if wsel_f is None:
print(f"Cannot read MMSUPPORT at 0x{mms:x}")
return
print(f"=== MmSupport @ 0x{mms:016x} ===")
print(f" WorkingSetExpansionLinks F=0x{wsel_f:x} B=0x{wsel_b:x}")
print(f" Flags = 0x{flags:x}")
ssp = flags & 1
bt = (flags >> 1) & 1
sl = (flags >> 2) & 1
mp = (flags >> 8) & 0xFF
print(f" SessionSpace={ssp} BeingTrimmed={bt} SessionLeader={sl}"
f" MemoryPriority={mp}")
print(f" PageFaultCount = {pfcount}")
print(f" PeakWorkingSetSize = {peak_ws}")
print(f" Min/MaxWorkingSetSize = {min_ws} / {max_ws}")
print(f" WorkingSetSize = {ws_size}")
print(f" VmWorkingSetList = 0x{wsl:016x}")
except Exception as e:
print(f"ros-mmsupport error: {e}")
ReactosMmSupport()
# ============================================================
# ros-mmranges
# ============================================================
class ReactosMmRanges(gdb.Command):
"""One-screen map of ARM3 virtual ranges. Resolves symbols at runtime.
Usage: ros-mmranges"""
def __init__(self):
super().__init__("ros-mmranges", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
print("=== ARM3 virtual address map (resolved from ntoskrnl symbols) ===")
def show(label, expr, deref=True):
addr = _try_eval_addr(expr)
if addr is None:
print(f" {label:30s} = <symbol not loaded>")
return
if deref:
# globals like MmPagedPoolStart are PVOID — read the pointer's value
val = read_u64(addr)
if val is not None:
print(f" {label:30s} = 0x{val:016x}")
else:
print(f" {label:30s} @ 0x{addr:x} <unreadable>")
else:
print(f" {label:30s} = 0x{addr:016x}")
print(f" {'PTE_BASE (amd64)':30s} = 0x{PTE_BASE_AMD64:016x}")
print(f" {'PTE_TOP (amd64)':30s} = 0x{PTE_TOP_AMD64:016x}")
# User-mode ceiling
show('MmHighestUserAddress', '&MmHighestUserAddress', deref=True)
show('MmUserProbeAddress', '&MmUserProbeAddress', deref=True)
# System range / pools
show('MmSystemRangeStart', '&MmSystemRangeStart', deref=True)
show('MmPagedPoolStart', '&MmPagedPoolStart', deref=True)
show('MmPagedPoolEnd', '&MmPagedPoolEnd', deref=True)
show('MmNonPagedPoolStart', '&MmNonPagedPoolStart', deref=True)
show('MmNonPagedPoolEnd', '&MmNonPagedPoolEnd', deref=True)
show('MmNonPagedSystemStart', '&MmNonPagedSystemStart', deref=True)
# System cache
show('MmSystemCacheStart', '&MmSystemCacheStart', deref=True)
show('MmSystemCacheEnd', '&MmSystemCacheEnd', deref=True)
# Misc ARM3 ranges
show('MiSystemViewStart', '&MiSystemViewStart', deref=True)
show('MmSessionBase', '&MmSessionBase', deref=True)
# Useful neighbours
show('MmPfnDatabase', '&MmPfnDatabase', deref=True)
show('PsIdleProcess', '&PsIdleProcess', deref=True)
show('MmSystemCacheWs (addr)', '&MmSystemCacheWs', deref=False)
# KUSER_SHARED_DATA — fixed VA on amd64
print(f" {'KUSER_SHARED_DATA (kernel)':30s} = 0xFFFFF78000000000")
print(f" {'KUSER_SHARED_DATA (user)':30s} = 0x000000007FFE0000")
ReactosMmRanges()
# ============================================================
# Locks / Sync : ros-eresource, ros-pushlock, ros-dispobj
# ============================================================
#
# Source-of-truth offsets (amd64):
# sdk/include/xdk/extypes.h:231 : _ERESOURCE
# sdk/include/ndk/extypes.h:624 : _EX_PUSH_LOCK
# sdk/include/xdk/ketypes.h:792,863.. : DISPATCHER_HEADER, KEVENT, KSEMAPHORE
# sdk/include/ndk/ketypes.h:405 : KOBJECTS enum
# commit 308dbcd42ab + sdk/include/ndk/pstypes.h:1550 :
# ETHREAD tail HeldPushLocks[8] + HeldPushLockCount (DBG only).
# _ERESOURCE field offsets (amd64). LIST_ENTRY=16, ULONG_PTR pad rules.
ERES_SYS_LIST = 0x00 # LIST_ENTRY
ERES_OWNER_TABLE = 0x10 # POWNER_ENTRY
ERES_ACTIVE_COUNT = 0x18 # SHORT
ERES_FLAG = 0x1A # USHORT
ERES_SHARED_WAITERS = 0x20 # PKSEMAPHORE
ERES_EXCL_WAITERS = 0x28 # PKEVENT
ERES_OWNER_ENTRY = 0x30 # OWNER_ENTRY (8 + 4 + 4pad = 16)
ERES_ACTIVE_ENTRIES = 0x40 # ULONG
ERES_CONTENTION = 0x44 # ULONG
ERES_NUM_SHARED = 0x48 # ULONG
ERES_NUM_EXCL = 0x4C # ULONG
ERES_RESERVED2 = 0x50 # PVOID
ERES_ADDRESS = 0x58 # PVOID / CreatorBackTraceIndex
ERES_SPINLOCK = 0x60 # KSPIN_LOCK
# OWNER_ENTRY: OwnerThread(8) + DUMMYUNION(4) + pad(4)
OWN_OWNER_THREAD = 0x00
OWN_OWNER_COUNT = 0x08 # bottom 30 bits
class ReactosEResource(gdb.Command):
"""Decode an ERESOURCE. Source: sdk/include/xdk/extypes.h:231.
Usage: ros-eresource <addr>"""
def __init__(self):
super().__init__("ros-eresource", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-eresource <ERESOURCE_addr>")
return
try:
addr = int(arg.strip(), 0)
except Exception:
print(f"Bad address: {arg.strip()}")
return
try:
owner_table = read_u64(addr + ERES_OWNER_TABLE)
active = read_i16(addr + ERES_ACTIVE_COUNT)
flag = read_u16(addr + ERES_FLAG)
shared_w = read_u64(addr + ERES_SHARED_WAITERS)
excl_w = read_u64(addr + ERES_EXCL_WAITERS)
owner_thr = read_u64(addr + ERES_OWNER_ENTRY + OWN_OWNER_THREAD)
own_word = read_u32(addr + ERES_OWNER_ENTRY + OWN_OWNER_COUNT)
active_e = read_u32(addr + ERES_ACTIVE_ENTRIES)
contention = read_u32(addr + ERES_CONTENTION)
num_shared = read_u32(addr + ERES_NUM_SHARED)
num_excl = read_u32(addr + ERES_NUM_EXCL)
spinlock = read_u64(addr + ERES_SPINLOCK)
except Exception as e:
print(f"ros-eresource read error: {e}")
return
if active is None:
print(f"Cannot read ERESOURCE at 0x{addr:x}")
return
print(f"=== ERESOURCE @ 0x{addr:016x} ===")
print(f" ActiveCount = {active}")
flag_bits = []
if flag & 0x10: flag_bits.append('NeverExclusive')
if flag & 0x20: flag_bits.append('ReleaseByOtherThread')
if flag & 0x80: flag_bits.append('OwnedExclusive')
flag_s = ', '.join(flag_bits) if flag_bits else '(none)'
print(f" Flag = 0x{flag:x} [{flag_s}]")
print(f" ContentionCount = {contention}")
print(f" NumberOfSharedWaiters = {num_shared}")
print(f" NumberOfExclusiveWaiters = {num_excl}")
print(f" ActiveEntries = {active_e}")
print(f" OwnerTable = 0x{owner_table:016x}")
print(f" SharedWaiters (KSEM*) = 0x{shared_w:016x}")
print(f" ExclusiveWaiters (KEVT*) = 0x{excl_w:016x}")
own_count = own_word & 0x3FFFFFFF if own_word is not None else 0
own_io = (own_word >> 0) & 1 if own_word is not None else 0
own_ref = (own_word >> 1) & 1 if own_word is not None else 0
print(f" OwnerEntry.OwnerThread = 0x{owner_thr:016x}")
print(f" OwnerEntry.OwnerCount = {own_count} IoBoosted={own_io} OwnerRefd={own_ref}")
print(f" SpinLock = 0x{spinlock:x}")
if owner_thr:
print(f" Use: ros-thread 0x{owner_thr:x}")
if shared_w:
print(f" Use: ros-dispobj 0x{shared_w:x} # KSEMAPHORE for shared waiters")
if excl_w:
print(f" Use: ros-dispobj 0x{excl_w:x} # KEVENT for exclusive waiters")
ReactosEResource()
class ReactosPushLock(gdb.Command):
"""Decode an EX_PUSH_LOCK (8 bytes). Source: sdk/include/ndk/extypes.h:624.
Optional 2nd arg = ETHREAD address: walk DBG HeldPushLocks[] tail array
(commit 308dbcd42ab; sdk/include/ndk/pstypes.h:1550).
Usage: ros-pushlock <addr> [ethread_addr]"""
def __init__(self):
super().__init__("ros-pushlock", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
args = arg.strip().split()
if not args:
print("Usage: ros-pushlock <addr> [ethread_addr]")
return
try:
addr = int(args[0], 0)
except Exception:
print(f"Bad address: {args[0]}")
return
val = read_u64(addr)
if val is None:
print(f"Cannot read EX_PUSH_LOCK at 0x{addr:x}")
return
locked = val & 1
waiting = (val >> 1) & 1
waking = (val >> 2) & 1
multi = (val >> 3) & 1
shared = (val >> 4)
print(f"=== EX_PUSH_LOCK @ 0x{addr:016x} = 0x{val:016x} ===")
print(f" Locked = {locked}")
print(f" Waiting = {waiting}")
print(f" Waking = {waking}")
print(f" MultipleShared = {multi}")
print(f" Shared (count or wait-block ptr) = 0x{shared:x}")
if waiting and shared:
wb_ptr = val & ~0xF
print(f" Likely first wait-block @ 0x{wb_ptr:x}")
if len(args) >= 2:
try:
ethread = int(args[1], 0)
except Exception:
print(f"Bad ethread: {args[1]}")
return
# ETHREAD HeldPushLocks/HeldPushLockCount live at the tail (DBG-only).
# We can't compute offset without symbols; try gdb type lookup first.
off_arr = _maybe_lookup_field_offset("struct _ETHREAD", "HeldPushLocks")
off_cnt = _maybe_lookup_field_offset("struct _ETHREAD", "HeldPushLockCount")
if off_arr is None or off_cnt is None:
print(f" (DBG ETHREAD HeldPushLocks not present — release build or symbols missing)")
return
cnt = read_u8(ethread + off_cnt) or 0
print(f" ETHREAD 0x{ethread:x} HeldPushLockCount = {cnt}")
for i in range(min(cnt, 8)):
p = read_u64(ethread + off_arr + i*8)
marker = ' <== this lock' if p == addr else ''
print(f" HeldPushLocks[{i}] = 0x{p:016x}{marker}")
ReactosPushLock()
# DISPATCHER_HEADER (amd64): Type@0, Size/etc@1..3, SignalState@4, WaitListHead@8 (16).
DISP_TYPE = 0x00
DISP_SIGNAL = 0x04
DISP_WAITLIST = 0x08
_KOBJECT_NAMES = {
0:'EventNotification', 1:'EventSync', 2:'Mutant', 3:'Process', 4:'Queue',
5:'Semaphore', 6:'Thread', 7:'Gate', 8:'TimerNotification', 9:'TimerSync',
18:'Apc', 19:'Dpc', 20:'DeviceQueue', 21:'EventPair', 22:'Interrupt',
23:'Profile',
}
class ReactosDispObj(gdb.Command):
"""Decode any dispatcher object via DISPATCHER_HEADER + walk WaitListHead.
Source: sdk/include/xdk/ketypes.h:792 ; KOBJECTS at ndk/ketypes.h:405.
KWAIT_BLOCK layout (NTDDI_WIN8) at xdk/ketypes.h:507.
Usage: ros-dispobj <addr>"""
def __init__(self):
super().__init__("ros-dispobj", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-dispobj <addr>")
return
try:
addr = int(arg.strip(), 0)
except Exception:
print(f"Bad address: {arg.strip()}")
return
try:
t = read_u8(addr + DISP_TYPE)
b1 = read_u8(addr + 1)
sz = read_u8(addr + 2)
inserted = read_u8(addr + 3)
sig = read_u32(addr + DISP_SIGNAL)
flink = read_u64(addr + DISP_WAITLIST)
blink = read_u64(addr + DISP_WAITLIST + 8)
except Exception as e:
print(f"ros-dispobj read error: {e}")
return
if t is None:
print(f"Cannot read DISPATCHER_HEADER at 0x{addr:x}")
return
# KOBJECTS encoded only in low 5 bits on some builds; check both
kname = _KOBJECT_NAMES.get(t, _KOBJECT_NAMES.get(t & 0x1F, f'?({t})'))
print(f"=== DispatcherHeader @ 0x{addr:016x} ===")
print(f" Type = 0x{t:02x} [{kname}]")
print(f" Abandoned/Sig= 0x{b1:02x}")
print(f" Size/Hand = 0x{sz:02x}")
print(f" Inserted/DBG = 0x{inserted:02x}")
print(f" SignalState = {sig if sig is not None and sig < 0x80000000 else sig}")
print(f" WaitListHead F=0x{flink:016x} B=0x{blink:016x}")
head = addr + DISP_WAITLIST
if not flink or flink == head:
print(" (no waiters)")
return
cur = flink
seen = set()
i = 0
# KWAIT_BLOCK NTDDI_WIN8: WaitListEntry@0, WaitType@0x10, BlockState@0x11,
# WaitKey@0x12, SpareLong@0x14 (_WIN64), Thread@0x18, Object@0x20.
while cur and cur != head and cur not in seen and i < 16:
seen.add(cur)
wb = cur # KWAIT_BLOCK starts at WaitListEntry
wait_type = read_u8(wb + 0x10)
wkey = read_u16(wb + 0x12)
thr = read_u64(wb + 0x18)
obj = read_u64(wb + 0x20)
print(f" [{i:2d}] WaitBlock@0x{wb:x} Thread=0x{thr:x} Obj=0x{obj:x}"
f" WaitType={wait_type} Key={wkey}")
cur = read_u64(wb)
if cur is None:
break
i += 1
if i and thr:
print(f" Use: ros-thread 0x{thr:x}")
ReactosDispObj()
# ============================================================
# Heap : ros-heap, ros-heap-freelist
# ============================================================
#
# Source: sdk/lib/rtl/heap.h:222 (_HEAP), :129 (_HEAP_FREE_ENTRY).
# RtlInitializeHeap (heap.c:117..225) sets:
# FreeHints : PLIST_ENTRY array, size DeCommitFreeBlockThreshold
# FreeHintBitmap: RTL_BITMAP whose Buffer = &FreeHints[DeCommitFreeBlockThreshold]
# Layout (amd64) — the critical fields:
HEAP_FLAGS = 0x70
HEAP_FORCE_FLAGS = 0x74
HEAP_VIRT_ALLOC_THRESHOLD = 0x9C
HEAP_SIGNATURE = 0xA0
HEAP_DECOMMIT_THRESHOLD = 0xB8 # SIZE_T (in HEAP_ENTRY units)
HEAP_TOTAL_FREE = 0xC8
HEAP_PROCHEAPS_INDEX = 0xD8 # USHORT
# FreeLists / FreeHintBitmap / FreeHints offsets vary by HEAP_SEGMENTS
# constant + 64-byte counters block. The HEAP_SEGMENTS=64 case yields:
# FreeLists @ 0x368 (16), LockVariable @ 0x378 ... after Counters/Tuning,
# FreeHintBitmap and FreeHints[] follow the heap header. We use gdb type
# lookup at runtime; fall back to fixed offsets that match heap.h:222.
HEAP_FREELISTS_FALLBACK = 0x368
HEAP_LOCK_VAR_FALLBACK = 0x378
# HEAP_FREE_ENTRY (amd64): HEAP_COMMON_ENTRY (16) + LIST_ENTRY FreeList (16) = 32
HFE_PREVIOUS_BLOCK_PRIV = 0x00 # encoded
HFE_SIZE = 0x08 # USHORT
HFE_FLAGS = 0x0A # UCHAR
HFE_SMALL_TAG_INDEX = 0x0B # UCHAR
HFE_PREV_SIZE = 0x0C # USHORT
HFE_SEGMENT_OFFSET = 0x0E # UCHAR
HFE_UNUSED_BYTES = 0x0F # UCHAR
HFE_LIST = 0x10 # LIST_ENTRY (Flink, Blink)
def _heap_field(addr, name, fallback):
"""Try to use real heap struct from gdb; fall back to fixed offset."""
off = _maybe_lookup_field_offset("struct _HEAP", name)
if off is None:
return read_u64(addr + fallback) if name != 'Flags' and name != 'ForceFlags' \
else read_u32(addr + fallback)
# Read by best size guess: pointers/SIZE_T => 8, ULONG => 4
if name in ('Flags','ForceFlags','Signature','VirtualMemoryThreshold'):
return read_u32(addr + off)
return read_u64(addr + off)
class ReactosHeap(gdb.Command):
"""Dump RTL _HEAP header + summary of FreeHintBitmap.
Source: sdk/lib/rtl/heap.h:222, sdk/lib/rtl/heap.c:117..225.
Usage: ros-heap <heap_addr>"""
def __init__(self):
super().__init__("ros-heap", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-heap <heap_addr>")
return
try:
h = int(arg.strip(), 0)
except Exception:
print(f"Bad address: {arg.strip()}")
return
try:
sig = read_u32(h + HEAP_SIGNATURE)
flags = read_u32(h + HEAP_FLAGS)
force = read_u32(h + HEAP_FORCE_FLAGS)
vthr = read_u32(h + HEAP_VIRT_ALLOC_THRESHOLD)
decom = read_u64(h + HEAP_DECOMMIT_THRESHOLD)
tfree = read_u64(h + HEAP_TOTAL_FREE)
phidx = read_u16(h + HEAP_PROCHEAPS_INDEX)
except Exception as e:
print(f"ros-heap read error: {e}")
return
if sig is None:
print(f"Cannot read HEAP at 0x{h:x}")
return
print(f"=== _HEAP @ 0x{h:016x} ===")
print(f" Signature = 0x{sig:08x} (expected 0xEEFFEEFF)")
print(f" Flags = 0x{flags:08x}")
print(f" ForceFlags = 0x{force:08x}")
print(f" VirtualMemoryThreshold = 0x{vthr:x} (HEAP_ENTRYs)")
print(f" DeCommitFreeBlockThr = 0x{decom:x} (HEAP_ENTRYs)")
print(f" TotalFreeSize = 0x{tfree:x} (HEAP_ENTRYs)")
print(f" ProcessHeapsListIndex = {phidx}")
# Try gdb to find FreeLists, FreeHintBitmap, FreeHints
fl_off = _maybe_lookup_field_offset("struct _HEAP", "FreeLists")
fh_off = _maybe_lookup_field_offset("struct _HEAP", "FreeHints")
fhb_off = _maybe_lookup_field_offset("struct _HEAP", "FreeHintBitmap")
if fl_off is None: fl_off = HEAP_FREELISTS_FALLBACK
flink = read_u64(h + fl_off)
blink = read_u64(h + fl_off + 8)
print(f" FreeLists @ +0x{fl_off:x} F=0x{flink:016x} B=0x{blink:016x}")
if fhb_off is not None:
bm_size = read_u32(h + fhb_off + 0x00) # SizeOfBitMap
bm_buf = read_u64(h + fhb_off + 0x08) # Buffer
if bm_size is not None and bm_buf:
set_bits = []
# Walk up to ceil(bm_size/32) ULONGs; collect first 10 set bits
ulongs = (bm_size + 31) // 32 if bm_size else 0
for u in range(min(ulongs, 1024)):
word = read_u32(bm_buf + u*4)
if not word:
continue
for bit in range(32):
if word & (1 << bit):
set_bits.append(u*32 + bit)
if len(set_bits) >= 10:
break
if len(set_bits) >= 10:
break
print(f" FreeHintBitmap SizeOfBitMap={bm_size} Buffer=0x{bm_buf:x}")
if fh_off is not None and set_bits:
print(f" First {len(set_bits)} set FreeHint bits:")
for b in set_bits:
ptr = read_u64(h + fh_off + b*8)
print(f" [bit {b:4d}] FreeHints[{b}] = 0x{ptr:016x}")
elif not set_bits:
print(f" FreeHintBitmap: (no bits set)")
print(f" Use: ros-heap-freelist 0x{h:x} [size_class]")
ReactosHeap()
class ReactosHeapFreeList(gdb.Command):
"""Walk a heap free list. size_class=0 walks Heap->FreeLists anchor;
nonzero walks Heap->FreeHints[size_class].
Source: sdk/lib/rtl/heap.h:129 (_HEAP_FREE_ENTRY).
Usage: ros-heap-freelist <heap_addr> [size_class=0] [max=64]"""
def __init__(self):
super().__init__("ros-heap-freelist", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
args = arg.strip().split()
if not args:
print("Usage: ros-heap-freelist <heap_addr> [size_class=0] [max=64]")
return
try:
h = int(args[0], 0)
except Exception:
print(f"Bad address: {args[0]}")
return
size_class = int(args[1], 0) if len(args) > 1 else 0
max_n = int(args[2], 0) if len(args) > 2 else 64
fl_off = _maybe_lookup_field_offset("struct _HEAP", "FreeLists") \
or HEAP_FREELISTS_FALLBACK
fh_off = _maybe_lookup_field_offset("struct _HEAP", "FreeHints")
if size_class == 0:
head = h + fl_off
print(f"=== Heap 0x{h:x} FreeLists anchor @ 0x{head:x} ===")
cur = read_u64(head)
if cur is None:
print(" (unreadable)")
return
else:
if fh_off is None:
print(" Cannot resolve _HEAP.FreeHints offset; pass size_class=0")
return
slot = h + fh_off + size_class*8
head = slot
cur = read_u64(slot)
print(f"=== Heap 0x{h:x} FreeHints[{size_class}] @ 0x{slot:x} -> 0x{cur:x} ===")
if not cur:
print(" (empty)")
return
seen = set()
i = 0
while cur and cur != head and cur not in seen and i < max_n:
seen.add(cur)
# FreeList LIST_ENTRY is at offset HFE_LIST inside HEAP_FREE_ENTRY.
entry = cur - HFE_LIST # cur points at LIST_ENTRY
sz = read_u16(entry + HFE_SIZE)
psz = read_u16(entry + HFE_PREV_SIZE)
fl = read_u8(entry + HFE_FLAGS) or 0
segoff = read_u8(entry + HFE_SEGMENT_OFFSET) or 0
f = read_u64(cur + 0)
b = read_u64(cur + 8)
print(f" [{i:3d}] entry@0x{entry:x} Size={sz} PrevSize={psz} "
f"Flags=0x{fl:02x} SegOff={segoff} F=0x{f:x} B=0x{b:x}")
cur = f
i += 1
if cur in seen:
print(f" *** loop detected at 0x{cur:x}")
elif i >= max_n:
print(f" ... truncated at {max_n}")
ReactosHeapFreeList()
# ============================================================
# I/O : ros-fileobj, ros-vpb, ros-devobj, ros-drvobj
# ============================================================
#
# Source: sdk/include/xdk/iotypes.h:1998 (_FILE_OBJECT),
# :189 (_VPB), :288 (_DEVICE_OBJECT), :2307 (_DRIVER_OBJECT).
# FILE_OBJECT (amd64) — Type(2)/Size(2)+pad(4)=8
FO_DEVICE_OBJECT = 0x08
FO_VPB = 0x10
FO_FS_CONTEXT = 0x18
FO_FS_CONTEXT2 = 0x20
FO_SECTION_PTR = 0x28
FO_PRIVATE_CACHEMAP = 0x30
FO_FINAL_STATUS = 0x38
FO_RELATED_FO = 0x40
FO_LOCK_OPERATION = 0x48 # 8 BOOLEANs in a row
FO_FLAGS = 0x50
FO_FILENAME = 0x58 # UNICODE_STRING (16)
FO_CURBYTE_OFFSET = 0x68
class ReactosFileObj(gdb.Command):
"""Decode a FILE_OBJECT. Source: sdk/include/xdk/iotypes.h:1998.
Usage: ros-fileobj <addr>"""
def __init__(self):
super().__init__("ros-fileobj", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-fileobj <FILE_OBJECT_addr>")
return
try:
f = int(arg.strip(), 0)
except Exception:
print(f"Bad address: {arg.strip()}")
return
try:
t = read_i16(f + 0)
sz = read_i16(f + 2)
dev = read_u64(f + FO_DEVICE_OBJECT)
vpb = read_u64(f + FO_VPB)
fc1 = read_u64(f + FO_FS_CONTEXT)
fc2 = read_u64(f + FO_FS_CONTEXT2)
sop = read_u64(f + FO_SECTION_PTR)
pcm = read_u64(f + FO_PRIVATE_CACHEMAP)
fst = read_u32(f + FO_FINAL_STATUS)
rfo = read_u64(f + FO_RELATED_FO)
booleans = read_mem(f + FO_LOCK_OPERATION, 8)
flags = read_u32(f + FO_FLAGS)
name = _read_unicode_string_at(f + FO_FILENAME) or ''
except Exception as e:
print(f"ros-fileobj read error: {e}")
return
if t is None:
print(f"Cannot read FILE_OBJECT at 0x{f:x}")
return
print(f"=== FILE_OBJECT @ 0x{f:016x} ===")
print(f" Type={t} Size={sz}")
print(f" DeviceObject = 0x{dev:016x}")
print(f" Vpb = 0x{vpb:016x}")
print(f" FsContext = 0x{fc1:016x}")
print(f" FsContext2 = 0x{fc2:016x}")
print(f" SectionObjectPointer = 0x{sop:016x}")
print(f" PrivateCacheMap = 0x{pcm:016x}")
print(f" FinalStatus = 0x{fst:08x}")
print(f" RelatedFileObject = 0x{rfo:016x}")
print(f" Flags = 0x{flags:08x}")
if booleans:
lo, dp, ra, wa, da, sr, sw, sd = booleans[:8]
print(f" LockOp={lo} DeletePend={dp} ReadAcc={ra} WriteAcc={wa} "
f"DelAcc={da} SharedR={sr} SharedW={sw} SharedD={sd}")
if name:
display = name[:80] + ('...' if len(name) > 80 else '')
print(f" FileName = \"{display}\"")
if dev: print(f" Use: ros-devobj 0x{dev:x}")
if vpb: print(f" Use: ros-vpb 0x{vpb:x}")
if sop: print(f" Use: ros-secptr 0x{sop:x}")
ReactosFileObj()
# VPB (amd64): Type(2)+Size(2)+Flags(2)+VolLabelLen(2)=8
VPB_FLAGS = 0x04
VPB_VOL_LABEL_LEN = 0x06
VPB_DEVICE_OBJECT = 0x08
VPB_REAL_DEVICE = 0x10
VPB_SERIAL_NUMBER = 0x18
VPB_REFERENCE_COUNT = 0x1C
VPB_VOLUME_LABEL = 0x20
class ReactosVpb(gdb.Command):
"""Decode a VPB. Source: sdk/include/xdk/iotypes.h:189.
Usage: ros-vpb <addr>"""
def __init__(self):
super().__init__("ros-vpb", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-vpb <VPB_addr>")
return
try:
v = int(arg.strip(), 0)
except Exception:
print(f"Bad address: {arg.strip()}")
return
try:
t = read_i16(v + 0)
sz = read_i16(v + 2)
flags = read_u16(v + VPB_FLAGS)
vlen = read_u16(v + VPB_VOL_LABEL_LEN)
dev = read_u64(v + VPB_DEVICE_OBJECT)
rdev = read_u64(v + VPB_REAL_DEVICE)
serial = read_u32(v + VPB_SERIAL_NUMBER)
refc = read_u32(v + VPB_REFERENCE_COUNT)
label_buf = read_mem(v + VPB_VOLUME_LABEL, min(vlen or 0, 64))
except Exception as e:
print(f"ros-vpb read error: {e}")
return
if t is None:
print(f"Cannot read VPB at 0x{v:x}")
return
print(f"=== VPB @ 0x{v:016x} ===")
print(f" Type={t} Size={sz} RefCount={refc} Serial=0x{serial:08x}")
flag_bits = []
if flags & 0x0001: flag_bits.append('Mounted')
if flags & 0x0002: flag_bits.append('Locked')
if flags & 0x0004: flag_bits.append('ShutdownDev')
if flags & 0x0008: flag_bits.append('IsRemoved')
if flags & 0x0010: flag_bits.append('RawMount')
if flags & 0x0020: flag_bits.append('RemoveMounted')
print(f" Flags = 0x{flags:04x} [{', '.join(flag_bits) or '(none)'}]")
label = ''
if label_buf:
try: label = label_buf.decode('utf-16-le').rstrip('\x00')
except: pass
print(f" VolumeLabel ({vlen} bytes) = \"{label}\"")
print(f" DeviceObject (FS volume) = 0x{dev:016x}")
print(f" RealDevice = 0x{rdev:016x}")
if dev: print(f" Use: ros-devobj 0x{dev:x}")
if rdev: print(f" Use: ros-devobj 0x{rdev:x}")
ReactosVpb()
# DEVICE_OBJECT (amd64, DECLSPEC_ALIGN(16)): Type(2)+Size(2)+RefCnt(4)=8
DO_DRIVER_OBJECT = 0x08
DO_NEXT_DEVICE = 0x10
DO_ATTACHED_DEVICE = 0x18
DO_CURRENT_IRP = 0x20
DO_TIMER = 0x28
DO_FLAGS = 0x30
DO_CHARACTERISTICS = 0x34
DO_VPB = 0x38
DO_DEVICE_EXTENSION = 0x40
DO_DEVICE_TYPE = 0x48
DO_STACK_SIZE = 0x4C # CCHAR
class ReactosDevObj(gdb.Command):
"""Decode a DEVICE_OBJECT. Source: sdk/include/xdk/iotypes.h:288.
Usage: ros-devobj <addr>"""
def __init__(self):
super().__init__("ros-devobj", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-devobj <DEVICE_OBJECT_addr>")
return
try:
d = int(arg.strip(), 0)
except Exception:
print(f"Bad address: {arg.strip()}")
return
try:
t = read_i16(d + 0)
sz = read_u16(d + 2)
refc = read_u32(d + 4)
drv = read_u64(d + DO_DRIVER_OBJECT)
nxt = read_u64(d + DO_NEXT_DEVICE)
att = read_u64(d + DO_ATTACHED_DEVICE)
cur = read_u64(d + DO_CURRENT_IRP)
flags = read_u32(d + DO_FLAGS)
chars = read_u32(d + DO_CHARACTERISTICS)
vpb = read_u64(d + DO_VPB)
ext = read_u64(d + DO_DEVICE_EXTENSION)
dtype = read_u32(d + DO_DEVICE_TYPE)
stack = read_u8(d + DO_STACK_SIZE)
except Exception as e:
print(f"ros-devobj read error: {e}")
return
if t is None:
print(f"Cannot read DEVICE_OBJECT at 0x{d:x}")
return
print(f"=== DEVICE_OBJECT @ 0x{d:016x} ===")
print(f" Type={t} Size={sz} RefCount={refc} StackSize={stack}")
print(f" DriverObject = 0x{drv:016x}")
print(f" NextDevice = 0x{nxt:016x}")
print(f" AttachedDevice = 0x{att:016x}")
print(f" CurrentIrp = 0x{cur:016x}")
print(f" Flags = 0x{flags:08x}")
print(f" Characteristics = 0x{chars:08x}")
print(f" Vpb = 0x{vpb:016x}")
print(f" DeviceExtension = 0x{ext:016x}")
print(f" DeviceType = 0x{dtype:08x}")
if drv: print(f" Use: ros-drvobj 0x{drv:x}")
if vpb: print(f" Use: ros-vpb 0x{vpb:x}")
if att: print(f" Use: ros-devobj 0x{att:x}")
if nxt: print(f" Use: ros-devobj 0x{nxt:x}")
ReactosDevObj()
# DRIVER_OBJECT (amd64): Type(2)+Size(2)+pad(4)=8
DRO_DEVICE_OBJECT = 0x08
DRO_FLAGS = 0x10
DRO_DRIVER_START = 0x18
DRO_DRIVER_SIZE = 0x20
DRO_DRIVER_SECTION = 0x28
DRO_DRIVER_EXTENSION = 0x30
DRO_DRIVER_NAME = 0x38 # UNICODE_STRING(16)
DRO_HW_DATABASE = 0x48 # PUNICODE_STRING
DRO_FAST_IO_DISPATCH = 0x50
DRO_DRIVER_INIT = 0x58
DRO_DRIVER_STARTIO = 0x60
DRO_DRIVER_UNLOAD = 0x68
DRO_MAJOR_FUNCTION = 0x70 # PDRIVER_DISPATCH[28]
_IRP_MJ_NAMES = [
'CREATE','CREATE_NAMED_PIPE','CLOSE','READ','WRITE','QUERY_INFORMATION',
'SET_INFORMATION','QUERY_EA','SET_EA','FLUSH_BUFFERS','QUERY_VOLUME_INFO',
'SET_VOLUME_INFO','DIRECTORY_CONTROL','FILE_SYSTEM_CONTROL','DEVICE_CONTROL',
'INTERNAL_DEVICE_CONTROL','SHUTDOWN','LOCK_CONTROL','CLEANUP','CREATE_MAILSLOT',
'QUERY_SECURITY','SET_SECURITY','POWER','SYSTEM_CONTROL','DEVICE_CHANGE',
'QUERY_QUOTA','SET_QUOTA','PNP',
]
def _resolve_symbol(addr):
if not addr:
return ''
try:
out = gdb.execute(f"info symbol 0x{addr:x}", to_string=True).strip()
return out.split(' in section')[0]
except Exception:
return ''
class ReactosDrvObj(gdb.Command):
"""Decode a DRIVER_OBJECT. Source: sdk/include/xdk/iotypes.h:2307.
Resolves MajorFunction[] non-default entries via 'info symbol'.
Usage: ros-drvobj <addr>"""
def __init__(self):
super().__init__("ros-drvobj", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-drvobj <DRIVER_OBJECT_addr>")
return
try:
d = int(arg.strip(), 0)
except Exception:
print(f"Bad address: {arg.strip()}")
return
try:
t = read_i16(d + 0); sz = read_i16(d + 2)
dev = read_u64(d + DRO_DEVICE_OBJECT)
flags = read_u32(d + DRO_FLAGS)
dst = read_u64(d + DRO_DRIVER_START)
dsz = read_u32(d + DRO_DRIVER_SIZE)
sec = read_u64(d + DRO_DRIVER_SECTION)
ext = read_u64(d + DRO_DRIVER_EXTENSION)
name = _read_unicode_string_at(d + DRO_DRIVER_NAME) or ''
fast = read_u64(d + DRO_FAST_IO_DISPATCH)
init_ = read_u64(d + DRO_DRIVER_INIT)
stio = read_u64(d + DRO_DRIVER_STARTIO)
unl = read_u64(d + DRO_DRIVER_UNLOAD)
except Exception as e:
print(f"ros-drvobj read error: {e}")
return
if t is None:
print(f"Cannot read DRIVER_OBJECT at 0x{d:x}")
return
print(f"=== DRIVER_OBJECT @ 0x{d:016x} ===")
print(f" Type={t} Size={sz}")
print(f" DriverName = \"{name}\"")
print(f" DeviceObject = 0x{dev:016x}")
print(f" Flags = 0x{flags:08x}")
print(f" DriverStart/Size = 0x{dst:016x} / 0x{dsz:x}")
print(f" DriverSection = 0x{sec:016x}")
print(f" DriverExtension = 0x{ext:016x}")
print(f" FastIoDispatch = 0x{fast:016x}")
for label, p in (('DriverInit',init_),('DriverStartIo',stio),('DriverUnload',unl)):
sym = _resolve_symbol(p) if p else ''
print(f" {label:16s} = 0x{p:016x} {sym}")
# Resolve MajorFunction[] — flag entries that don't point to the default
default = read_u64(d + DRO_MAJOR_FUNCTION) # MJ_CREATE — usually IopInvalidDeviceRequest
print(f" MajorFunction[CREATE default] = 0x{default:016x} {_resolve_symbol(default)}")
for i, mname in enumerate(_IRP_MJ_NAMES):
p = read_u64(d + DRO_MAJOR_FUNCTION + i*8)
if p and p != default:
sym = _resolve_symbol(p)
print(f" [{i:2d}] MJ_{mname:22s} 0x{p:016x} {sym}")
if dev: print(f" Use: ros-devobj 0x{dev:x}")
ReactosDrvObj()
# ============================================================
# Object manager : ros-obj, ros-handles
# ============================================================
#
# Source: sdk/include/ndk/obtypes.h:111 (OBJECT_TO_OBJECT_HEADER),
# :430 (_OBJECT_HEADER_NAME_INFO), :485 (_OBJECT_HEADER),
# :379 (_OBJECT_TYPE).
# OBJECT_HEADER (amd64): PointerCount(8) + HandleCount(8) + Type(8) +
# NameInfoOff(1)+HandleInfoOff(1)+QuotaInfoOff(1)+Flags(1) + pad(4)
# + ObjectCreateInfo(8) + SecurityDescriptor(8) + Body
OH_POINTER_COUNT = 0x00
OH_HANDLE_COUNT = 0x08
OH_TYPE = 0x10
OH_NAME_INFO_OFFSET = 0x18
OH_HANDLE_INFO_OFFSET = 0x19
OH_QUOTA_INFO_OFFSET = 0x1A
OH_FLAGS = 0x1B
OH_BODY = 0x30 # QUAD-aligned
# OBJECT_HEADER_NAME_INFO: Directory(8) + Name UNICODE_STRING(16)
OHNI_DIRECTORY = 0x00
OHNI_NAME = 0x08
# OBJECT_TYPE.Name offset (amd64): ERESOURCE(0x68) + LIST_ENTRY(0x10) = 0x78
OT_NAME = 0x78
class ReactosObj(gdb.Command):
"""Walk back from an object body to its OBJECT_HEADER and decode it.
Resolves Type->Name and (if present) the OBJECT_HEADER_NAME_INFO Name.
Source: sdk/include/ndk/obtypes.h:111, :430, :485.
Usage: ros-obj <object_body_addr>"""
def __init__(self):
super().__init__("ros-obj", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-obj <object_addr>")
return
try:
body = int(arg.strip(), 0)
except Exception:
print(f"Bad address: {arg.strip()}")
return
hdr = body - OH_BODY
try:
pc = read_u64(hdr + OH_POINTER_COUNT)
hc = read_u64(hdr + OH_HANDLE_COUNT)
otp = read_u64(hdr + OH_TYPE)
ni_o = read_u8(hdr + OH_NAME_INFO_OFFSET)
hi_o = read_u8(hdr + OH_HANDLE_INFO_OFFSET)
qi_o = read_u8(hdr + OH_QUOTA_INFO_OFFSET)
flg = read_u8(hdr + OH_FLAGS)
except Exception as e:
print(f"ros-obj read error: {e}")
return
if pc is None:
print(f"Cannot read OBJECT_HEADER at 0x{hdr:x}")
return
# Sign-extend PointerCount/HandleCount to int64 for sane print
if pc & (1 << 63): pc -= 1 << 64
if hc & (1 << 63): hc -= 1 << 64
print(f"=== Object @ 0x{body:016x} (header @ 0x{hdr:x}) ===")
print(f" PointerCount = {pc}")
print(f" HandleCount = {hc}")
print(f" Type = 0x{otp:016x}")
print(f" NameInfoOffset = {ni_o}")
print(f" HandleInfoOffset = {hi_o}")
print(f" QuotaInfoOffset = {qi_o}")
flags_bits = []
if flg & 0x01: flags_bits.append('NewObject')
if flg & 0x02: flags_bits.append('KernelObject')
if flg & 0x04: flags_bits.append('CreatorInfo')
if flg & 0x08: flags_bits.append('Exclusive')
if flg & 0x10: flags_bits.append('Permanent')
if flg & 0x20: flags_bits.append('SecurityInfo')
if flg & 0x40: flags_bits.append('SingleProcess')
if flg & 0x80: flags_bits.append('DeferDelete')
print(f" Flags = 0x{flg:02x} [{', '.join(flags_bits) or '(none)'}]")
if otp:
tname = _read_unicode_string_at(otp + OT_NAME)
if tname:
print(f" Type->Name = \"{tname}\"")
if ni_o:
ni_addr = hdr - ni_o
ni_name = _read_unicode_string_at(ni_addr + OHNI_NAME)
ni_dir = read_u64(ni_addr + OHNI_DIRECTORY)
if ni_name is not None:
print(f" ObjName = \"{ni_name}\" Directory=0x{ni_dir:x}")
ReactosObj()
class ReactosHandles(gdb.Command):
"""Walk EPROCESS->ObjectTable and dump in-use handles.
Source: sdk/include/ndk/extypes.h:787 (_HANDLE_TABLE),
ntoskrnl/ex/handle.c (TableCode levels: 0=low,1=mid,2=high).
Usage: ros-handles <eprocess_addr> [max=64]"""
def __init__(self):
super().__init__("ros-handles", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
args = arg.strip().split()
if not args:
print("Usage: ros-handles <eprocess_addr> [max=64]")
return
try:
eproc = int(args[0], 0)
except Exception:
print(f"Bad address: {args[0]}")
return
max_n = int(args[1], 0) if len(args) > 1 else 64
ot_off = _maybe_lookup_field_offset("struct _EPROCESS", "ObjectTable")
if ot_off is None:
print(" (cannot resolve EPROCESS.ObjectTable offset; symbols missing)")
return
ht = read_u64(eproc + ot_off)
if not ht:
print(f" EPROCESS->ObjectTable = NULL (terminating?)")
return
tc = read_u64(ht) # TableCode at offset 0 NTDDI_WINXP+
hc = read_u32(ht + 0x70) # HandleCount approximate
if tc is None:
print(f"Cannot read HANDLE_TABLE at 0x{ht:x}")
return
level = tc & 3
base = tc & ~3
print(f"=== HANDLE_TABLE @ 0x{ht:016x} (process 0x{eproc:x}) ===")
print(f" TableCode = 0x{tc:x} Level={level} Base=0x{base:x} HandleCount~{hc}")
# On amd64: HANDLE_TABLE_ENTRY = 16. LOW_LEVEL_ENTRIES = 4096/16 = 256.
LOW = 256
MID = 512 # 4096/8
TAGBITS = 2
shown = 0
# Yield (handle_index, entry_addr) for each entry
def iter_low(low_table_base, base_index):
for i in range(LOW):
yield base_index + i, low_table_base + i*16
def iter_mid(mid_table, base_index):
for j in range(MID):
low = read_u64(mid_table + j*8)
if not low:
continue
yield from iter_low(low, base_index + j*LOW)
if level == 0:
it = iter_low(base, 0)
elif level == 1:
it = iter_mid(base, 0)
else:
def iter_high():
for k in range(MID):
mid = read_u64(base + k*8)
if not mid:
continue
yield from iter_mid(mid, k*MID*LOW)
it = iter_high()
printed_first_obj_chain = 0
for idx, ea in it:
if shown >= max_n:
print(f" ... truncated at {max_n}")
break
obj_attr = read_u64(ea + 0)
grant = read_u32(ea + 8)
if obj_attr is None or obj_attr == 0:
continue
# NextFreeTableEntry: low bits == 0, high == small int? we just skip 0.
# Mask the attribute bits (bottom 3) to recover object pointer.
obj = obj_attr & ~0x7
handle = (idx << TAGBITS)
print(f" [{shown:3d}] Handle=0x{handle:04x} Object=0x{obj:016x}"
f" Granted=0x{grant:08x}")
if printed_first_obj_chain < 3 and obj:
# Auto-resolve type name only (cheap)
hdr = obj - OH_BODY
otp = read_u64(hdr + OH_TYPE)
if otp:
tname = _read_unicode_string_at(otp + OT_NAME)
if tname:
print(f" Type=\"{tname}\"")
printed_first_obj_chain += 1
shown += 1
ReactosHandles()
# ============================================================
# CPU state : ros-prcb
# ============================================================
#
# Source: sdk/include/ndk/amd64/ketypes.h:653 (_KPRCB), :960 (_KIPCR).
class ReactosPrcb(gdb.Command):
"""Read KPCR via $gs_base, follow to KPRCB, dump key scheduler fields.
Source: sdk/include/ndk/amd64/ketypes.h:960 (KIPCR), :653 (KPRCB).
Usage: ros-prcb"""
def __init__(self):
super().__init__("ros-prcb", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
try:
gs_base = int(gdb.parse_and_eval("$gs_base")) & 0xFFFFFFFFFFFFFFFF
except Exception:
try:
gs_base = int(gdb.parse_and_eval("$gs.base")) & 0xFFFFFFFFFFFFFFFF
except Exception:
print("ros-prcb: cannot read $gs_base / $gs.base")
return
# KIPCR layout: NtTib union puts Self at offset 0x18 (NtTib.SubSystemTib)
# CurrentPrcb at offset 0x20. Irql at 0x50. Prcb (embedded) at 0x180.
try:
self_pcr = read_u64(gs_base + 0x18)
cur_prcb_p = read_u64(gs_base + 0x20)
irql = read_u8(gs_base + 0x50)
except Exception as e:
print(f"ros-prcb read KPCR error: {e}")
return
prcb = gs_base + 0x180 # embedded Prcb
if cur_prcb_p and cur_prcb_p != prcb:
print(f" (note: CurrentPrcb=0x{cur_prcb_p:x} differs from embedded 0x{prcb:x})")
prcb = cur_prcb_p
elif cur_prcb_p is None:
print(f" (note: CurrentPrcb unreadable; using embedded 0x{prcb:x})")
try:
mxcsr = read_u32(prcb + 0x00)
number = read_u16(prcb + 0x04)
int_req = read_u8(prcb + 0x06)
idle_h = read_u8(prcb + 0x07)
cur_thr = read_u64(prcb + 0x08)
nxt_thr = read_u64(prcb + 0x10)
idl_thr = read_u64(prcb + 0x18)
except Exception as e:
print(f"ros-prcb read KPRCB error: {e}")
return
print(f"=== KPCR @ 0x{gs_base:016x} ===")
print(f" Self = {fmt_ptr(self_pcr)}")
print(f" CurrentPrcb = {fmt_ptr(cur_prcb_p)}")
print(f" Irql = 0x{irql:x}" if irql is not None else " Irql = ?")
print(f"=== KPRCB @ 0x{prcb:016x} ===")
print(f" MxCsr = 0x{mxcsr:08x}" if mxcsr is not None else " MxCsr = ?")
print(f" Number = {number}" if number is not None else " Number = ?")
print(f" InterruptRequest= {int_req if int_req is not None else '?'} IdleHalt={idle_h if idle_h is not None else '?'}")
print(f" CurrentThread = {fmt_ptr(cur_thr)}")
print(f" NextThread = {fmt_ptr(nxt_thr)}")
print(f" IdleThread = {fmt_ptr(idl_thr)}")
# BuildType/CpuStepping/Model — offsets vary; use gdb if symbols loaded.
bt_off = _maybe_lookup_field_offset("struct _KPRCB", "BuildType")
cs_off = _maybe_lookup_field_offset("struct _KPRCB", "CpuStepping")
cm_off = _maybe_lookup_field_offset("struct _KPRCB", "CpuModel")
if bt_off is not None:
bt = read_u8(prcb + bt_off)
print(f" BuildType = 0x{bt:x} (CHK={bt&1} MP={bool(bt&2)})")
if cs_off is not None and cm_off is not None:
cs = read_u8(prcb + cs_off); cm = read_u8(prcb + cm_off)
print(f" CpuStepping/Model = 0x{cs:x} / 0x{cm:x}")
# DpcData[2] — offset varies; try gdb
dpc_off = _maybe_lookup_field_offset("struct _KPRCB", "DpcData")
if dpc_off is not None:
# KDPC_DATA: DpcListHead(16) DpcLock(8) DpcQueueDepth(LONG) DpcCount(LONG)
for i in range(2):
base = prcb + dpc_off + i*0x20
qd = read_u32(base + 0x18)
cnt = read_u32(base + 0x1C)
print(f" DpcData[{i}] DpcQueueDepth={qd} DpcCount={cnt}")
if cur_thr: print(f" Use: ros-thread 0x{cur_thr:x}")
ReactosPrcb()
# ============================================================
# Pagefile / Working set : ros-pagefile, ros-wsl
# ============================================================
#
# Source: ntoskrnl/include/internal/mm.h:518 (_MMPAGING_FILE),
# :29 (MmNumberOfPagingFiles), :532 (MmPagingFile[]).
# sdk/include/ndk/mmtypes.h:869 (_MMWSL).
# MMPAGING_FILE (amd64)
PF_SIZE = 0x00
PF_MAX = 0x08
PF_MIN = 0x10
PF_FREESPACE = 0x18
PF_CUR_USAGE = 0x20
PF_FILE_OBJECT = 0x28
PF_PAGEFILE_NAME = 0x30 # UNICODE_STRING(16)
PF_BITMAP = 0x40
PF_FILE_HANDLE = 0x48
class ReactosPagefile(gdb.Command):
"""Dump MmPagingFile[] entries. Source: ntoskrnl/include/internal/mm.h:518.
Usage: ros-pagefile [idx]"""
def __init__(self):
super().__init__("ros-pagefile", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
# Resolve MmPagingFile[] base and MmNumberOfPagingFiles
try:
arr = int(gdb.parse_and_eval("&MmPagingFile"))
except Exception:
print("ros-pagefile: MmPagingFile symbol unavailable")
return
try:
n_addr = int(gdb.parse_and_eval("&MmNumberOfPagingFiles"))
n = read_u32(n_addr) or 0
except Exception:
n = 0
idx = None
if arg.strip():
try:
idx = int(arg.strip(), 0)
except Exception:
print(f"Bad idx: {arg.strip()}")
return
print(f"=== MmPagingFile[] MmNumberOfPagingFiles={n} ===")
rng = range(idx, idx+1) if idx is not None else range(min(n or 16, 16))
for i in rng:
pf = read_u64(arr + i*8)
if pf is None:
continue
if pf == 0:
if idx is not None:
print(f" [{i}] (NULL)")
continue
sz = read_u64(pf + PF_SIZE)
mxs = read_u64(pf + PF_MAX)
mns = read_u64(pf + PF_MIN)
free = read_u64(pf + PF_FREESPACE)
cur = read_u64(pf + PF_CUR_USAGE)
fo = read_u64(pf + PF_FILE_OBJECT)
name = _read_unicode_string_at(pf + PF_PAGEFILE_NAME) or ''
bm = read_u64(pf + PF_BITMAP)
fh = read_u64(pf + PF_FILE_HANDLE)
print(f" [{i}] MMPAGING_FILE @ 0x{pf:016x}")
print(f" Name = \"{name}\"")
print(f" Size/Min/Max = {sz} / {mns} / {mxs} pages")
print(f" FreeSpace = {free} CurrentUsage={cur}")
print(f" FileObject = 0x{fo:016x}")
print(f" Bitmap = 0x{bm:016x}")
print(f" FileHandle = 0x{fh:016x}")
if fo: print(f" Use: ros-fileobj 0x{fo:x}")
ReactosPagefile()
class ReactosWsl(gdb.Command):
"""Find Process->Vm.VmWorkingSetList (MMWSL) and dump it.
Source: sdk/include/ndk/mmtypes.h:869, :943 (MMSUPPORT.VmWorkingSetList).
Usage: ros-wsl <eprocess_addr>"""
def __init__(self):
super().__init__("ros-wsl", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
if not arg.strip():
print("Usage: ros-wsl <eprocess_addr>")
return
try:
eproc = int(arg.strip(), 0)
except Exception:
print(f"Bad address: {arg.strip()}")
return
vm_off = _maybe_lookup_field_offset("struct _EPROCESS", "Vm")
if vm_off is None:
print(" (cannot resolve EPROCESS.Vm offset; symbols missing)")
return
# MMSUPPORT.VmWorkingSetList @ +0x30 (verified by ros-mmsupport above)
wsl = read_u64(eproc + vm_off + 0x30)
if not wsl:
print(f" EPROCESS 0x{eproc:x} VmWorkingSetList = NULL")
return
try:
ff = read_u32(wsl + 0x00)
fdyn = read_u32(wsl + 0x04)
last = read_u32(wsl + 0x08)
nxt = read_u32(wsl + 0x0C)
wsle = read_u64(wsl + 0x10)
last_in = read_u32(wsl + 0x18)
non_dir = read_u32(wsl + 0x1C)
ht = read_u64(wsl + 0x20)
ht_size = read_u32(wsl + 0x28)
committed = read_u32(wsl + 0x2C)
ht_start = read_u64(wsl + 0x30)
ht_high = read_u64(wsl + 0x38)
except Exception as e:
print(f"ros-wsl read error: {e}")
return
if ff is None:
print(f"Cannot read MMWSL at 0x{wsl:x}")
return
print(f"=== MMWSL @ 0x{wsl:016x} (EPROCESS 0x{eproc:x}) ===")
print(f" FirstFree = {ff}")
print(f" FirstDynamic = {fdyn}")
print(f" LastEntry = {last}")
print(f" NextSlot = {nxt}")
print(f" Wsle (PMMWSLE) = 0x{wsle:016x}")
print(f" LastInitializedWsle = {last_in}")
print(f" NonDirectCount = {non_dir}")
print(f" HashTable = 0x{ht:016x} (size={ht_size})")
print(f" CommittedPageTables = {committed}")
print(f" HashTableStart = 0x{ht_start:016x}")
print(f" HighestPermittedHash = 0x{ht_high:016x}")
# AddressCreationLock — chain to ros-pushlock if locatable
acl_off = _maybe_lookup_field_offset("struct _EPROCESS", "AddressCreationLock")
if acl_off is not None:
print(f" Use: ros-pushlock 0x{eproc + acl_off:x} 0x<ethread>"
f" # AddressCreationLock")
ReactosWsl()
class ReactosTriage(gdb.Command):
"""Universal triage dump for any kdb-reached assertion/bugcheck.
Runs: ros-where, ros-prcb, regs, bt, ros-fault, ros-pslist, ros-mmranges,
then prints class-specific follow-up hints. Suitable for blind invocation
by triage.sh on any asserted state, regardless of bug class."""
def __init__(self):
super().__init__("ros-triage", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
cmds = [
("=== CPU mode ===", "ros-where"),
("=== KPRCB ===", "ros-prcb"),
("=== regs ===", "info reg"),
("=== stack ===", "bt 30"),
("=== ros-fault ===", "ros-fault"),
("=== process list ===", "ros-pslist"),
("=== ARM3 VA map ===", "ros-mmranges"),
]
for header, cmd in cmds:
print(header)
try:
gdb.execute(cmd)
except Exception as e:
print(f" [{cmd} failed: {e}]")
print("")
print("=== Follow-up dumpers (run manually after classification) ===")
print(" heap.c:1185 (freelist) : ros-heap <heap>; check rsi/rdi for heap ptr")
print(" heap.c:448 (bitmap) : ros-heap-freelist <heap> <size>")
print(" Cc 0x23 (MmMapView) : ros-secptr <fileobj>; ros-cachemap <scm>;")
print(" ros-section <section>")
print(" 0x1A Bad PTE : ros-pte-va <va>; ros-pfn <pfn>;")
print(" ros-translate <cr3> <va>")
print(" Rmap dup (sec.c:1967) : grep 'Rmap trace match' /tmp/repro26-com1.log")
print(" (MmDumpRmapTrace already in-kernel)")
print(" Heap dup/coalesce : grep 'Heap trace match' /tmp/repro26-com1.log")
print(" (RtlpDumpHeapTrace already in-kernel)")
print(" Unknown : ros-trapframes; ros-callchain $rsp")
ReactosTriage()
# ============================================================
# Done — print available commands
# ============================================================
print("")
print("=== ReactOS GDB helpers loaded (non-aggressive) ===")
print("Connect with: ros-connect [:port]")
print("")
print("Commands (after connecting):")
print(" ros-where Show CPU context (kernel/user64/compat32)")
print(" ros-load-symbols Auto-find ntoskrnl and load symbols")
print(" ros-load-at <addr> Load ntoskrnl at a known base")
print(" ros-load-module <a> <p> Load symbols for any module")
print(" ros-lsmod [--load] List/load kernel modules")
print(" ros-addr2mod <addr> Find module owning an address")
print(" ros-thread <addr> Inspect KTHREAD")
print(" ros-waiters <addr> Find waiters on dispatcher object")
print(" ros-irp <addr> Inspect IRP")
print(" ros-fault [tf_addr] Dump faulting context from trap frame (RBP)")
print(" ros-trapframes [lo] [hi] Scan for KTRAP_FRAMEs (incl CS=0x23)")
print(" ros-frame-regs <rsp> [pat] Recover pushed non-volatile regs")
print(" ros-callchain [rsp] [n] Heuristic stack scan")
print(" ros-kdb-bt [rsp] [n] Asserting callchain past kdb/RtlAssert preamble")
print(" ros-findmod <addr> Find module base by MZ scan")
print(" ros-verify <base> <path> Compare loaded vs build binary")
print(" ros-pslist List active processes")
print(" ros-findproc <name> Find process by name")
print(" ros-threads <eproc> List threads and stack symbols for a process")
print(" ros-gdt [base] [limit] Dump GDT entries")
print(" ros-xp <cr3> <va> [n] Read virtual mem from any process (via page tables)")
print(" ros-translate <cr3> <va> Walk page tables, show PML4/PDPT/PD/PT entries")
print("Pool debugging:")
print(" ros-pool-page <addr> Walk pool blocks on a page")
print(" ros-pool-block <addr> Inspect a single pool block")
print(" ros-pool-scan <start> [n] Scan for pool corruption")
print(" ros-pool-find <tag> <start> [n] Find blocks by tag")
print(" ros-pool-crash Auto-analyze pool crash")
print("MM diagnostics:")
print(" ros-rmap-arm [base] Arm 4 hbreaks for MM rmap leak diagnostic")
print("ARM3 PTE / PFN:")
print(" ros-pte <pte_addr> Decode 64-bit PTE at cell address (Hard/Soft/Trans/Proto)")
print(" ros-pte-va <va> Find self-mapped PTE for VA and decode it")
print(" ros-pfn <pfn-or-addr> Decode MMPFN: ShareCount, e1, OriginalPte, u4.PteFrame")
print(" ros-rmap <pfn-or-addr> Walk per-PFN rmap chain: each (Process, Address) entry")
print(" ros-proto-walk <ca> [max] Walk CONTROL_AREA's proto array, flag Hard.Valid violations")
print(" ros-mapping-walk <va> <n> ros-pte-va for n pages of a contiguous VA range")
print("MM struct dumpers:")
print(" ros-section <addr> Decode _SECTION; chains to ros-segment")
print(" ros-segment <addr> Decode _SEGMENT; suggests ros-controlarea / ros-proto-walk")
print(" ros-controlarea <addr> Decode _CONTROL_AREA + flags; chains to embedded SUBSECTION")
print(" ros-subsection <addr> Decode _SUBSECTION; suggests ros-proto-walk")
print(" ros-secptr <addr> Decode _SECTION_OBJECT_POINTERS; chains to cachemap/CA")
print(" ros-cachemap <addr> Decode _ROS_SHARED_CACHE_MAP; suggests ros-vacb-list")
print(" ros-vacb-list <scm> [max] Walk CacheMapVacbListHead, list each ROS_VACB")
print(" ros-vacb <addr> Decode one _ROS_VACB")
print(" ros-vad <addr> Decode _MMVAD/_MMVAD_SHORT (VA range, flags, CA)")
print(" ros-vad-tree <eproc> [max] Walk EPROCESS->VadRoot AVL tree, dump each VAD")
print(" ros-mmsupport <eproc> Dump _MMSUPPORT (WS sizes, fault count, flags)")
print(" ros-mmranges One-screen ARM3 VA map (pools/cache/PTE_BASE/etc.)")
print("Locks/Sync:")
print(" ros-eresource <addr> Decode ERESOURCE (active/contention/owner/waiters)")
print(" ros-pushlock <addr> [eth] Decode EX_PUSH_LOCK; optional DBG HeldPushLocks walk")
print(" ros-dispobj <addr> Decode DISPATCHER_HEADER + walk WaitListHead/KWAIT_BLOCKs")
print("Heap (RTL user-mode):")
print(" ros-heap <addr> _HEAP header + FreeHintBitmap summary")
print(" ros-heap-freelist <h> [sz] Walk Heap->FreeLists or FreeHints[size_class]")
print("I/O:")
print(" ros-fileobj <addr> Decode FILE_OBJECT (FileName, flags, chains)")
print(" ros-vpb <addr> Decode VPB (volume label, refcount, flags)")
print(" ros-devobj <addr> Decode DEVICE_OBJECT (driver, stack, vpb, ext)")
print(" ros-drvobj <addr> Decode DRIVER_OBJECT + resolve MajorFunction[] symbols")
print("Object manager:")
print(" ros-obj <addr> Walk to OBJECT_HEADER; resolve Type->Name + ObjName")
print(" ros-handles <eproc> [max] Walk EPROCESS->ObjectTable, dump in-use handles")
print("CPU state:")
print(" ros-prcb Read $gs_base KPCR/KPRCB (cur/idle thread, irql, dpc)")
print("Pagefile / Working set:")
print(" ros-pagefile [idx] Dump MmPagingFile[idx] or summarize all")
print(" ros-wsl <eproc> Dump EPROCESS->Vm.VmWorkingSetList (MMWSL)")
print("Triage:")
print(" ros-triage Universal dump for any kdb-reached assert")
print("")
end

Debugging ReactOS Boot with GDB + QEMU: Real Mode, Protected Mode & Long Mode

Comprehensive guide for debugging the entire boot chain: MBR, VBR (NTFS boot sector), FreeLDR, and the ReactOS kernel. Covers the tricky parts that waste hours if you don't know them.


The Fundamental Problem: QEMU is x86-64, Your Boot Code is 16-bit

QEMU's GDB stub always reports the x86-64 register set — even when the CPU is in 16-bit real mode (which it is during MBR/VBR/early FreeLDR). This causes several issues:

Problem Symptom Solution
Architecture mismatch Remote 'g' packet reply is too long Always use set architecture i386:x86-64
Wrong disassembly 16-bit mov dl,[BootDrive] shows as (bad) Disassemble manually: x/20bx addr, decode offline
Segment math invisible GDB shows flat addresses, ignores CS/DS/ES Compute physical addr yourself: segment * 16 + offset
set architecture i8086 doesn't work GDB rejects the register packet from QEMU Don't use i8086 — always i386:x86-64

Rule: Always connect with set architecture i386:x86-64, always compute CS*16 + IP yourself for real-mode addresses.


.gdbinit Setup

The .gdbinit in the repo auto-connects to QEMU's gdbstub on :1234 and detects CPU mode:

set architecture i386:x86-64    # MUST be before target remote
set disassembly-flavor intel
set pagination off
set confirm off
target remote :1234

The Python block then reads CR0/EFER to detect real vs long mode and adjusts behavior.

Auto-load Safety

GDB may refuse to auto-load .gdbinit from the repo directory. Fix:

# In ~/.config/gdb/gdbinit or ~/.gdbinit:
add-auto-load-safe-path /path/to/reactos/.gdbinit

Or bypass entirely:

gdb -x .gdbinit         # explicit load
gdb -nh -nx             # skip ALL init files (for manual debugging)

Avoiding .gdbinit Conflicts

If .gdbinit tries to connect and QEMU isn't running, GDB hangs/errors. When running GDB manually:

# Skip repo .gdbinit — use -nh (no home init) -nx (no local init):
gdb -q -nh -nx -batch -ex "set arch i386:x86-64" -ex "target remote :1234" ...

# Or run from /tmp to avoid auto-loading:
cd /tmp && gdb -q -batch ...

GEF / PEDA / pwndbg Conflicts

If you have GEF (source ~/.gef-*.py) in your home .gdbinit, it may interfere. Use -nh to skip it, or add the ReactOS repo to the safe-path and let the repo .gdbinit handle everything.


Starting QEMU for Debugging

Boot Sector / FreeLDR Debugging (Real Mode)

qemu-system-x86_64 \
    -drive file=disk.img,format=raw,if=ide \
    -m 512 -display none \
    -S                      # Freeze at reset vector (0xFFF0)
    -gdb tcp::1234 \        # GDB stub
    -no-reboot \            # Exit on triple fault instead of rebooting
    -nic none

Important flags:

  • -S: Freeze CPU at boot. Without this, the boot completes (or crashes) before you connect.
  • -no-reboot: On triple fault, QEMU exits instead of silently rebooting. Essential for catching crashes.
  • No -enable-kvm: TCG (software emulation) is more reliable for hardware breakpoints in real mode. KVM sometimes drops hbreaks.

Kernel Debugging (Long Mode)

qemu-system-x86_64 \
    -drive file=disk.img,format=raw,if=ide \
    -m 512 -display none \
    -gdb tcp::1234 \        # Don't use -S, let it boot to the kernel
    -serial file:serial.log \
    -enable-kvm             # KVM is fine for long mode debugging

Real Mode Debugging Cookbook

Connecting

gdb -q -nh -nx -batch \
    -ex "set arch i386:x86-64" \
    -ex "target remote :1234" \
    -ex "..." \
    -ex "detach" -ex "quit"

Breaking at the VBR

The MBR loads at 0x7C00, relocates itself, then loads the VBR also at 0x7C00. To catch the VBR:

hbreak *0x7c00          # First hit = MBR
continue
delete breakpoints
hbreak *0x7c00          # Second hit = VBR
continue
# Now at VBR entry. Verify:
x/8cb 0x7c03            # Should show "NTFS    " for NTFS

Why hbreak not break? Software breakpoints (break) write INT3 into memory, but the MBR/VBR code hasn't been loaded yet when you set the breakpoint. Hardware breakpoints (hbreak) use CPU debug registers and work regardless.

Limit: QEMU supports only 4 hardware breakpoints simultaneously (DR0-DR3).

Reading 16-bit Code

GDB decodes everything as 64-bit, making 16-bit disassembly unreadable:

(gdb) x/5i 0x7c54
   0x7c54: mov    $0x35e86f00,%esp    # WRONG — this is 16-bit code

Workaround 1: Dump raw bytes and decode offline:

x/32bx 0x7c54

Then use ndisasm or objdump:

# Extract bytes to a file, disassemble as 16-bit:
echo "31c0 8ed8 8ec0 ..." | xxd -r -p > /tmp/code.bin
ndisasm -b16 /tmp/code.bin

Workaround 2: Use the build's binary as reference. The VBR is compiled from boot/freeldr/bootsect/ntfs.S to build/boot/freeldr/bootsect/ntfs.bin:

objdump -b binary -m i8086 -D build/boot/freeldr/bootsect/ntfs.bin | less

Addresses start at 0, add 0x7C00 to get runtime addresses.

Computing Physical Addresses

In real mode, the CPU uses segment:offset addressing. Physical address = segment * 16 + offset.

# GDB shows: CS=0x0000, RIP=0x7C54
# Physical = 0x0000 * 16 + 0x7C54 = 0x7C54

# After VBR loads extra code:
# CS=0x0000, code at 0x7E00 (sector 2 of VBR)

# FreeLDR loaded at FREELDR_BASE=0xF800:
# CS=0x0F80, RIP=0x0200
# Physical = 0x0F80 * 16 + 0x0200 = 0xFA00

When examining memory with x/..., always use the PHYSICAL address:

x/16bx 0xf800          # FreeLDR entry point (physical)
x/4wx 0x20000           # MFT record buffer at segment 0x2000
x/4wx 0x90000           # INDX buffer at segment 0x9000

Segment Registers

p/x $es                 # ES segment value
p/x $ds
p/x $cs
p/x $fs
# Physical address of ES:BX buffer:
print/x $es * 16 + $rbx

Finding Error Handlers in the VBR

The VBR binary has error strings at fixed offsets. Find the handler addresses from the build:

# Find PrintDiskError and PrintFileSystemError addresses:
data = open('build/boot/freeldr/bootsect/ntfs.bin', 'rb').read()
for label, text in [("DiskError", b"Disk error"), ("FSError", b"File system error")]:
    idx = data.find(text)
    if idx < 0: continue
    for i in range(idx-10, idx):
        if i >= 0 and data[i] == 0xBE:  # mov si, imm16
            val = int.from_bytes(data[i+1:i+3], 'little')
            if val == 0x7c00 + idx:
                print(f"Print{label} @ 0x{0x7c00+i:x}")

Then set breakpoints:

hbreak *0x7d74          # PrintDiskError (example)
hbreak *0x7d7c          # PrintFileSystemError (example)
continue
# When hit, check registers and stack for the cause

Finding INT 13h Calls

data = open('build/boot/freeldr/bootsect/ntfs.bin', 'rb').read()
for i in range(len(data)-1):
    if data[i] == 0xCD and data[i+1] == 0x13:
        print(f"INT 13h @ 0x{0x7c00+i:x}")

Examining VBR BPB Fields

# After VBR is loaded at 0x7C00:
x/hx 0x7c0b            # BytesPerSector
x/bx 0x7c0d            # SectorsPerCluster
x/wx 0x7c1c            # HiddenSectors
x/bx 0x7c24            # BootDrive (0x80 = first HD)
x/gx 0x7c28            # TotalSectors
x/gx 0x7c30            # MftLocation (cluster)

FreeLDR Debugging (Real Mode to Long Mode Transition)

FreeLDR (freeldr.sys) is loaded by the VBR at FREELDR_BASE = 0xF800 (physical). Its entry point is a JMP at offset 0 that lands at offset 0x200 (the Startup label in arch/realmode/amd64.S).

Key Physical Addresses

Address What Source
0xF800 FreeLDR entry (JMP instruction) FREELDR_BASE in x86common.h
0xFA00 Startup label (real-mode init) offset 0x200 in freeldr.sys
0x10000 FREELDR_PE_BASE (PE image for long mode) x86common.h
0x1000 Page tables (PML4) PML4_ADDRESS
0x2000 Page tables (PDP) PDP_ADDRESS
0x3000 Page tables (PD) PD_ADDRESS
0x6F00 Real-mode stack STACK16ADDR

FreeLDR Boot Sequence

  1. Startup — CLI, zero segments, save boot drive
  2. EnableA20 — enable A20 address line
  3. RelocateFreeLdr — relocate if not at FREELDR_BASE (usually a no-op)
  4. writestr("Starting FreeLoader...") — first visible output on screen
  5. CheckFor64BitSupport — CPUID check
  6. lgdt — load GDT for long mode
  7. BuildPageTables — identity-map first 1GB with 2MB pages
  8. ExitToLongMode — set CR4.PAE, load CR3, set EFER.LME, set CR0.PG+PE
  9. Far jump to 64-bit code segment

Breaking at FreeLDR Milestones

Find addresses from the binary:

data = open('build/boot/freeldr/freeldr/freeldr.sys', 'rb').read()
# "Starting FreeLoader" string
idx = data.find(b'Starting FreeLoader')
print(f"Msg_Starting @ 0x{0xF800+idx:x}")
# CPUID instruction (0F A2)
for i in range(0x200, 0x1000):
    if data[i] == 0x0F and data[i+1] == 0xA2:
        print(f"CPUID @ 0x{0xF800+i:x}"); break
# lgdt (0F 01 16)
for i in range(0x200, 0x1000):
    if data[i:i+3] == b'\x0F\x01\x16':
        print(f"lgdt @ 0x{0xF800+i:x}"); break
# mov cr0 (0F 22 C0) — enables protected/long mode
for i in range(0x200, 0x1000):
    if data[i:i+3] == b'\x0F\x22\xC0':
        print(f"mov cr0,eax @ 0x{0xF800+i:x}")

Triple Fault Detection

If QEMU exits immediately with -no-reboot, the boot code triple-faulted. Common causes:

  1. Bad page tablesBuildPageTables writes to 0x1000-0x3FFF. If something else uses that memory, pages are corrupt.
  2. GDT not set uplgdt must run before entering protected mode.
  3. Bad far jump — The ljmp to long-mode CS must use the correct GDT selector.
  4. NX/EFER issuewrmsr to EFER must set LME before enabling paging.

Debug with:

hbreak *<lgdt_addr>     # Verify GDT is loaded
continue
# Check GDT pointer:
x/6bx <gdtptr_addr>    # limit (2 bytes) + base (4 bytes)

hbreak *<mov_cr0_addr>  # Verify CR0 transition
continue
p/x $rax                # Should have PE+PG bits set
p/x $cr3                # Should point to PML4 (0x1000)

Memory Layout During Boot

VBR Memory Map (boot/freeldr/bootsect/ntfs.S)

Physical Address Segment:Offset Purpose
0x7C00-0x7DFF 0000:7C00 VBR sector 0 (BPB + boot code)
0x7E00-0x83FF 0000:7E00 VBR sectors 1-3 (extra boot code)
0x8000-0x9FFF 0800:0000 MFT record 0 ($MFT) — persistent for data runs
0x20000-0x203FF 2000:0000 Current MFT record being read
0x30000-0x30FFF 3000:0000 Temp cluster buffer (ReadInode)
0x90000-0x90FFF 9000:0000 INDX block buffer
0xA000-0xA3FF 0A00:0000 freeldr.sys MFT record
0xF800+ 0F80:0000 FreeLDR loaded here

FreeLDR Memory Map (boot/freeldr/freeldr/include/arch/pc/x86common.h)

Physical Address Macro Purpose
0x1000 PML4_ADDRESS PML4 page table
0x2000 PDP_ADDRESS PDP page table
0x3000 PD_ADDRESS PD page table
0x6F00 STACK16ADDR 16-bit real-mode stack
0x8000 BSS_START FreeLDR BSS / shared data
0xF800 FREELDR_BASE FreeLDR load address (16-bit entry)
0x10000 FREELDR_PE_BASE FreeLDR PE image (64-bit)

Conflict risk: The VBR uses 0x8000 (0x800:0) for MFT record 0 storage. FreeLDR's BSS_START is also 0x8000. If the VBR's MFT buffer overlaps FreeLDR's BSS, data corruption occurs during the transition. The VBR must use the MFT buffer ONLY before jumping to FreeLDR.


Kernel Debugging (Long Mode)

Once ReactOS is in long mode, use the .gdbinit commands:

ros-load-symbols              # Auto-find ntoskrnl and load DWARF
ros-lsmod --load              # Walk PsLoadedModuleList, load all driver symbols
ros-addr2mod 0xFFFFF880xxxx   # Find which driver owns an address
ros-thread 0xFFFFF880xxxx     # Inspect KTHREAD (stack, APC state)
ros-waiters 0xFFFFF880xxxx    # Find threads waiting on a KEVENT/KMUTEX
ros-irp 0xFFFFF880xxxx        # Inspect IRP structure

Loading Symbols Manually

# Find .text section RVA:
# objdump -h build/drivers/filesystems/ntfs/ntfs.sys | grep .text
# Example: .text at RVA 0x1000

# If driver loaded at 0xFFFFF88001234000:
add-symbol-file build/drivers/filesystems/ntfs/ntfs.sys 0xFFFFF88001235000
# (base + .text RVA)

Common Pitfalls

1. Stale QEMU Instance

If a previous QEMU is still holding the disk image or port 1234:

# Check:
pgrep -af qemu-system
ss -tln | grep 1234
# Kill:
pkill -f "qemu-system.*yourdisk"

GDB connecting to a stale QEMU shows wrong RIP (e.g., 0xBDF4 in BIOS area).

2. GDB -batch Exit Code 144

Exit code 144 = signal 16 (killed by sandbox/shell). Common when:

  • Running kill of QEMU in the same command chain
  • GDB Python scripts take too long
  • Sandbox kills backgrounded processes

Fix: Don't kill QEMU in the same && chain as GDB. Use separate commands.

3. Hardware Breakpoint Fires at Wrong Time

Address 0x7F0E in the VBR might ALSO be hit during BIOS POST or MBR execution. Fix: use unique addresses (error handlers like 0x7D7C are rarely hit by BIOS), or use the two-break technique:

hbreak *0x7c00    # Catch MBR
continue
delete
hbreak *0x7c00    # Catch VBR
continue
delete
hbreak *0x7d7c    # NOW set your real breakpoint
continue

4. ReadNonResidentAttribute Returns 0 But Isn't Checked

In the VBR's FreeLDR load loop, ReadNonResidentAttribute is called with pushad/popad wrappers — the return value in AX is discarded. An off-by-one in the cluster count can cause a silent failed read of the last cluster.

5. Page Table Memory Conflicts

FreeLDR's BuildPageTables writes to physical 0x1000-0x3FFF. If the VBR stored anything there (like MFT record 0), it gets overwritten. The current VBR uses 0x800:0 = physical 0x8000 for MFT record 0, which is safe.

6. BootDrive in the BPB

The NTFS VBR code at offset 0x24 stores the BIOS drive number. If the formatter leaves it as 0, the VBR reads from floppy (drive 0) instead of the hard disk (0x80). The ReactOS NTFS formatter (mkntfs.c) must set BiosDriveNumber = 0x80.

7. HiddenSectors in the BPB

The VBR adds HiddenSectors to every sector read (ReadSectors function). If the formatter leaves it as 0, all reads are off by the partition offset (typically 2048 sectors). The formatter must set HiddenSectors = partition_starting_offset / sector_size.


Batch Debugging Pattern

For quick one-shot checks (no interactive GDB needed):

# Start QEMU frozen
qemu-system-x86_64 -drive file=disk.img,format=raw -display none \
    -S -gdb tcp::1234 -no-reboot &
sleep 2

# Run GDB batch, skip all init files
timeout 15 gdb -q -nh -nx -batch \
    -ex "set arch i386:x86-64" \
    -ex "target remote :1234" \
    -ex "hbreak *0x7d7c" \
    -ex "c" \
    -ex "p/x \$rip" \
    -ex "p/x \$rax" \
    -ex "x/4wx 0x20000" \
    -ex "detach" -ex "quit"

# Clean up
kill %1 2>/dev/null

Using timeout

Always wrap GDB batch in timeout — if the breakpoint never fires, GDB hangs forever:

timeout 15 gdb -q -nh -nx -batch ...

Analyzing VBR Binary Offline

# Disassemble the NTFS VBR as 16-bit code:
objdump -b binary -m i8086 -D build/boot/freeldr/bootsect/ntfs.bin | less

# Find specific patterns:
python3 -c "
data = open('build/boot/freeldr/bootsect/ntfs.bin','rb').read()
# All INT 13h calls:
for i in range(len(data)-1):
    if data[i]==0xCD and data[i+1]==0x13:
        print(f'INT 13h @ offset 0x{i:x} (runtime 0x{0x7c00+i:x}')
# All JC (carry flag jumps — disk error checks):
import struct
for i in range(len(data)-1):
    if data[i]==0x72:
        target = i + 2 + struct.unpack_from('b', data, i+1)[0]
        print(f'JC @ 0x{i:x} -> 0x{target:x}')
"

# Compare VBR on disk vs build:
python3 -c "
disk = open('disk.img','rb'); disk.seek(2048*512); vbr=disk.read(8192)
expected = open('build/boot/freeldr/bootsect/ntfs.bin','rb').read()
match = vbr[0x54:0x54+len(expected)-0x54] == expected[0x54:]
print(f'Boot code matches: {match}')
if not match:
    for i in range(0x54, min(len(expected),8192)):
        if i<len(vbr) and vbr[i]!=expected[i]:
            print(f'First diff @ 0x{i:x}'); break
"

Quick Reference: Register Meanings at Error Handlers

At PrintDiskError:

  • AH: INT 13h error code (0x01=invalid, 0x20=controller failure, etc.)
  • DL: Drive number used (should be 0x80 for HD)
  • CX: Sector count being read
  • EAX: Sector number attempted

At PrintFileSystemError:

  • ES: Segment of the buffer being checked
  • SI/DI: Offsets into MFT record or index
  • EAX: Attribute type being searched, or comparison result
  • Check stack for return address to identify which check failed
#!/usr/bin/env python3
"""
generate_stubs.py - Auto-generate ReactOS ntoskrnl stubs for a Windows driver
Reads a Windows .sys PE, compares its imports against ReactOS ntoskrnl exports,
and generates:
1. Spec file entries (ntoskrnl.spec additions)
2. Stub C source files grouped by subsystem
3. ntos.cmake additions
Usage:
python3 generate_stubs.py <windows_driver.sys> [--ros-ntoskrnl <path>] [--output-dir <dir>]
Example:
python3 generate_stubs.py /tmp/ntfs-win.sys --ros-ntoskrnl build/ntoskrnl/ntoskrnl.exe
"""
import argparse
import os
import sys
from collections import defaultdict
try:
import pefile
except ImportError:
print("Error: pefile module required. Install with: pip install pefile", file=sys.stderr)
sys.exit(1)
# Map function name prefixes to ntoskrnl subsystem directories
PREFIX_TO_SUBSYSTEM = {
'Cc': ('cc', 'Cache Manager'),
'Cm': ('config','Configuration Manager'),
'Dbg': ('kd', 'Kernel Debugger'),
'Etw': ('etw', 'Event Tracing for Windows'),
'Ex': ('ex', 'Executive'),
'FsRtl':('fsrtl', 'File System Run-Time Library'),
'Hal': ('ke', 'Kernel (HAL wrappers)'),
'Io': ('io/iomgr', 'I/O Manager'),
'Ke': ('ke', 'Kernel'),
'Lsa': ('se', 'Security (LSA)'),
'Mm': ('mm', 'Memory Manager'),
'Nls': ('rtl', 'Runtime Library (NLS)'),
'Nt': ('io/iomgr', 'I/O Manager (Nt syscalls)'),
'Ob': ('ob', 'Object Manager'),
'Po': ('po', 'Power Manager'),
'Ps': ('ps', 'Process/Thread Manager'),
'Rtl': ('rtl', 'Runtime Library'),
'Se': ('se', 'Security'),
'Tm': ('tm', 'Transaction Manager'),
'Wmi': ('wmi', 'WMI'),
'Zw': ('io/iomgr', 'I/O Manager (Zw syscalls)'),
'_': ('rtl', 'Runtime Library (CRT)'),
}
# Known extern globals (not functions)
KNOWN_EXTERNS = {
'MmBadPointer', 'MmHighestUserAddress', 'MmSystemRangeStart',
'MmUserProbeAddress', 'KdDebuggerEnabled', 'KdDebuggerNotPresent',
'NlsMbOemCodePageTag', 'NlsOemLeadByteInfo', 'NlsMbCodePageTag',
'SeExports', 'IoFileObjectType', 'IoDeviceObjectType', 'IoDriverObjectType',
'PsProcessType', 'PsThreadType', 'SeTokenObjectType',
'TmEnlistmentObjectType', 'TmResourceManagerObjectType', 'TmTransactionObjectType',
'CcFastMdlReadWait', 'CcFastReadNotPossible', 'CcFastReadWait',
'InitializeSListHead',
}
# Known function signatures from DDK headers
# Format: 'FuncName': ('ret_type', ['arg_types...'], 'spec_args')
# spec_args uses: ptr, long, int64, str, wstr, double
KNOWN_SIGNATURES = {
# Cache Manager
'CcCopyReadEx': ('BOOLEAN', 'ptr ptr long long ptr ptr long'),
'CcCopyWriteEx': ('BOOLEAN', 'ptr ptr long long ptr long'),
'CcCopyWriteWontFlush': ('BOOLEAN', 'ptr ptr long'),
'CcAsyncCopyRead': ('BOOLEAN', 'ptr ptr long long ptr ptr ptr ptr long'),
'CcCoherencyFlushAndPurgeCache': ('VOID', 'ptr ptr long ptr long'),
'CcFlushCacheToLsn': ('VOID', 'ptr ptr'),
'CcInitializeCacheMapEx':('VOID', 'ptr ptr long ptr ptr long'),
'CcIsThereDirtyLoggedPages': ('BOOLEAN', 'ptr ptr'),
'CcSetAdditionalCacheAttributesEx': ('VOID', 'ptr long'),
'CcSetFileSizesEx': ('NTSTATUS', 'ptr ptr'),
'CcSetLogHandleForFileEx': ('VOID', 'ptr ptr ptr ptr long'),
'CcSetLoggedDataThreshold': ('VOID', 'ptr long'),
'CcSetParallelFlushFile': ('VOID', 'ptr ptr'),
'CcUnmapFileOffsetFromSystemCache': ('VOID', 'ptr ptr long long'),
# ETW - all return NTSTATUS or ULONG
'EtwRegister': ('NTSTATUS', 'ptr ptr ptr ptr'),
'EtwUnregister': ('NTSTATUS', 'long long'),
'EtwWrite': ('NTSTATUS', 'long long ptr ptr long ptr'),
'EtwWriteTransfer': ('NTSTATUS', 'long long ptr ptr long ptr'),
'EtwSetInformation': ('NTSTATUS', 'long long long ptr long'),
'EtwActivityIdControl': ('NTSTATUS', 'long ptr'),
# FsRtl
'FsRtlAreVolumeStartupApplicationsComplete': ('BOOLEAN', ''),
'FsRtlCancellableWaitForSingleObject': ('NTSTATUS', 'ptr ptr ptr'),
'FsRtlCheckOplockEx': ('NTSTATUS', 'ptr ptr long ptr ptr ptr ptr ptr'),
'FsRtlGetSectorSizeInformation': ('NTSTATUS', 'ptr ptr'),
'FsRtlOplockFsctrlEx': ('NTSTATUS', 'ptr ptr long long'),
'FsRtlOplockBreakH': ('NTSTATUS', 'ptr ptr long ptr ptr ptr ptr ptr'),
'FsRtlOplockBreakToNoneEx': ('NTSTATUS', 'ptr ptr long ptr ptr ptr'),
'FsRtlValidateReparsePointBuffer': ('NTSTATUS', 'long ptr'),
'FsRtlIsMobileOS': ('BOOLEAN', ''),
'FsRtlDismountComplete': ('VOID', 'ptr long'),
'FsRtlUpdateDiskCounters': ('VOID', 'long long long long long long'),
'FsRtlOplockIsFastIoPossible': ('BOOLEAN', 'ptr'),
'FsRtlOplockIsSharedRequest': ('BOOLEAN', 'ptr'),
'FsRtlOplockKeysEqual': ('BOOLEAN', 'ptr ptr'),
'FsRtlMdlReadEx': ('NTSTATUS', 'ptr ptr long ptr ptr'),
'FsRtlPrepareMdlWriteEx':('NTSTATUS', 'ptr ptr long ptr ptr'),
'FsRtlIsExtentDangling': ('BOOLEAN', 'long long'),
'FsRtlQueryMaximumVirtualDiskNestingLevel': ('NTSTATUS', 'ptr'),
'FsRtlGetVirtualDiskNestingLevel': ('NTSTATUS', 'ptr ptr'),
'FsRtlVolumeDeviceToCorrelationId': ('NTSTATUS', 'ptr ptr'),
'FsRtlNotifyVolumeEventEx': ('NTSTATUS', 'ptr long ptr'),
# IO
'IoGetIoPriorityHint': ('LONG', 'ptr'),
'IoSetIoPriorityHint': ('NTSTATUS', 'ptr long'),
'IoCheckShareAccessEx': ('NTSTATUS', 'long long ptr ptr long ptr'),
'IoSetShareAccessEx': ('VOID', 'long long ptr ptr'),
'IoWithinStackLimits': ('BOOLEAN', 'long long'),
'IoSynchronousCallDriver': ('NTSTATUS', 'ptr ptr'),
'IoSetMasterIrpStatus': ('VOID', 'ptr long'),
'IoVolumeDeviceToGuid': ('NTSTATUS', 'ptr ptr'),
# Ke
'KeFlushIoBuffers': ('VOID', 'ptr long long'),
'KeQueryActiveProcessorCountEx': ('ULONG', 'long'),
'KeQueryUnbiasedInterruptTime': ('ULONGLONG', ''),
'KeSetCoalescableTimer': ('BOOLEAN', 'ptr long long long ptr'),
'KeExpandKernelStackAndCalloutEx': ('NTSTATUS', 'ptr ptr long long ptr'),
# Mm
'MmMdlPageContentsState': ('NTSTATUS', 'ptr long'),
'MmMdlPagesAreZero': ('BOOLEAN', 'ptr'),
# Ob
'ObDereferenceObjectDeferDelete': ('VOID', 'ptr'),
# Po
'PoRegisterCoalescingCallback': ('NTSTATUS', 'ptr ptr ptr ptr'),
# Ps
'PsEnterPriorityRegion': ('VOID', ''),
'PsLeavePriorityRegion': ('VOID', ''),
'PsIsDiskCountersEnabled': ('BOOLEAN', ''),
'PsUpdateDiskCounters': ('VOID', 'long long long long long long'),
# Se
'SeAccessCheckEx': ('BOOLEAN', 'ptr ptr ptr long long long ptr ptr ptr ptr ptr long'),
'SeQuerySecurityAttributesToken': ('NTSTATUS', 'ptr ptr long ptr ptr'),
'SeQuerySecurityAttributesTokenAccessInformation': ('NTSTATUS', 'ptr ptr long ptr ptr'),
'SeShouldCheckForAccessRightsFromParent': ('BOOLEAN', 'ptr ptr'),
# Rtl
'RtlComputeCrc32': ('ULONG', 'long ptr long'),
'RtlCrc64': ('ULONGLONG', 'long long ptr long'),
'RtlDuplicateUnicodeString': ('NTSTATUS', 'long ptr ptr'),
'RtlFormatMessage': ('NTSTATUS', 'ptr long long long long ptr ptr long ptr'),
'RtlCreateHashTable': ('BOOLEAN', 'ptr long long'),
'RtlDeleteHashTable': ('VOID', 'ptr'),
'RtlInsertEntryHashTable': ('BOOLEAN', 'ptr ptr long ptr'),
'RtlRemoveEntryHashTable': ('BOOLEAN', 'ptr ptr ptr'),
'RtlLookupEntryHashTable': ('ptr', 'ptr long ptr'),
'RtlInitEnumerationHashTable': ('BOOLEAN', 'ptr ptr'),
'RtlEnumerateEntryHashTable': ('ptr', 'ptr ptr'),
'RtlEndEnumerationHashTable': ('VOID', 'ptr ptr'),
'RtlGetNextEntryHashTable': ('ptr', 'ptr ptr'),
'RtlNumberOfClearBitsInRange': ('ULONG', 'ptr long long'),
'RtlNumberOfSetBitsInRange': ('ULONG', 'ptr long long'),
'RtlDecompressBufferEx2': ('NTSTATUS', 'long ptr long ptr long long ptr long'),
'RtlDecompressFragmentEx': ('NTSTATUS', 'long ptr long ptr long long long ptr long long'),
'RtlQueryModuleInformation': ('NTSTATUS', 'ptr long ptr'),
'RtlLookupFirstMatchingElementGenericTableAvl': ('ptr', 'ptr ptr ptr'),
'RtlCheckPortableOperatingSystem': ('VOID', 'ptr'),
'RtlExtractBitMap': ('VOID', 'ptr ptr long long'),
'RtlFindAceByType': ('BOOLEAN', 'ptr long ptr'),
'RtlOwnerAcesPresent': ('BOOLEAN', 'ptr'),
'RtlReplaceSidInSd': ('NTSTATUS', 'ptr ptr ptr ptr'),
# CRT
'_snprintf_s': ('int', 'ptr long long str'),
'_snwprintf_s': ('int', 'ptr long long wstr'),
# Tm
'TmCommitComplete': ('NTSTATUS', 'ptr ptr'),
'TmCreateEnlistment': ('NTSTATUS', 'ptr long ptr ptr ptr long long ptr'),
'TmCurrentTransaction': ('ptr', ''),
'TmDereferenceEnlistmentKey': ('NTSTATUS', 'ptr ptr'),
'TmEnableCallbacks': ('NTSTATUS', 'ptr ptr ptr'),
'TmGetTransactionId': ('VOID', 'ptr ptr'),
'TmIsKTMCommitCoordinator': ('BOOLEAN', 'ptr'),
'TmIsTransactionActive': ('BOOLEAN', 'ptr'),
'TmPrepareComplete': ('NTSTATUS', 'ptr ptr'),
'TmReadOnlyEnlistment': ('NTSTATUS', 'ptr ptr'),
'TmRecoverResourceManager': ('NTSTATUS', 'ptr'),
'TmReferenceEnlistmentKey': ('NTSTATUS', 'ptr ptr'),
'TmRequestOutcomeEnlistment': ('NTSTATUS', 'ptr ptr'),
'TmRollbackComplete': ('NTSTATUS', 'ptr ptr'),
'TmRollbackEnlistment': ('NTSTATUS', 'ptr ptr'),
# Zw
'ZwCreateResourceManager': ('NTSTATUS', 'ptr long ptr ptr ptr long ptr'),
'ZwCreateTransactionManager': ('NTSTATUS', 'ptr long ptr ptr long long'),
'ZwOpenEnlistment': ('NTSTATUS', 'ptr long ptr long ptr'),
'ZwOpenResourceManager': ('NTSTATUS', 'ptr long ptr ptr long'),
'ZwQueryInformationTransactionManager': ('NTSTATUS', 'ptr long ptr long ptr'),
'ZwQuerySecurityAttributesToken': ('NTSTATUS', 'ptr ptr long ptr long ptr'),
'ZwRecoverEnlistment': ('NTSTATUS', 'ptr ptr'),
'ZwRecoverTransactionManager': ('NTSTATUS', 'ptr'),
'ZwUpdateWnfStateData': ('NTSTATUS', 'ptr ptr ptr long ptr long long'),
}
def get_subsystem(funcname):
"""Determine which ntoskrnl subsystem a function belongs to."""
for prefix, (subdir, desc) in sorted(PREFIX_TO_SUBSYSTEM.items(), key=lambda x: -len(x[0])):
if funcname.startswith(prefix):
return subdir, desc
return 'ex', 'Executive (misc)'
def get_spec_args(funcname):
"""Get spec file argument types for a function."""
if funcname in KNOWN_SIGNATURES:
return KNOWN_SIGNATURES[funcname][1]
# Default: guess based on common patterns
return 'ptr' # minimal, will need manual fixup
def get_return_type(funcname):
"""Get the C return type for a function."""
if funcname in KNOWN_SIGNATURES:
return KNOWN_SIGNATURES[funcname][0]
if funcname.startswith('Zw') or funcname.startswith('Nt'):
return 'NTSTATUS'
if funcname.startswith('Se') and 'Audit' in funcname:
return 'VOID'
return 'NTSTATUS'
def get_return_value(ret_type):
"""Get the default return value for a given type."""
return {
'VOID': None,
'NTSTATUS': 'STATUS_NOT_IMPLEMENTED',
'BOOLEAN': 'FALSE',
'BOOL': 'FALSE',
'ULONG': '0',
'LONG': '0',
'ULONGLONG': '0ULL',
'int': '0',
'ptr': 'NULL',
}.get(ret_type, 'STATUS_NOT_IMPLEMENTED')
def generate_stub_c(funcname, ret_type):
"""Generate a C stub function."""
ret_val = get_return_value(ret_type)
if ret_val is None:
return (
f"VOID\n"
f"NTAPI\n"
f"{funcname}(VOID)\n"
f"{{\n"
f" UNIMPLEMENTED;\n"
f"}}\n"
)
else:
c_ret = ret_type if ret_type not in ('ptr',) else 'PVOID'
return (
f"{c_ret}\n"
f"NTAPI\n"
f"{funcname}(VOID) /* FIXME: args unknown, check DDK */\n"
f"{{\n"
f" UNIMPLEMENTED;\n"
f" return {ret_val};\n"
f"}}\n"
)
def main():
parser = argparse.ArgumentParser(description='Generate ReactOS ntoskrnl stubs from Windows driver PE')
parser.add_argument('driver', help='Path to Windows .sys driver file')
parser.add_argument('--ros-ntoskrnl', default='build/ntoskrnl/ntoskrnl.exe',
help='Path to ReactOS ntoskrnl.exe (default: build/ntoskrnl/ntoskrnl.exe)')
parser.add_argument('--ros-spec', default='ntoskrnl/ntoskrnl.spec',
help='Path to ReactOS ntoskrnl.spec')
parser.add_argument('--output-dir', default='ntoskrnl',
help='Output directory for stub files (default: ntoskrnl)')
parser.add_argument('--dll', default='ntoskrnl.exe',
help='DLL to compare imports against (default: ntoskrnl.exe)')
parser.add_argument('--dry-run', action='store_true',
help='Print what would be generated without writing files')
args = parser.parse_args()
# Load Windows driver
try:
win_pe = pefile.PE(args.driver)
except Exception as e:
print(f"Error loading {args.driver}: {e}", file=sys.stderr)
sys.exit(1)
driver_name = os.path.basename(args.driver)
print(f"Driver: {driver_name}")
print(f"Machine: {'x64' if win_pe.FILE_HEADER.Machine == 0x8664 else 'x86'}")
# Get driver imports for target DLL
win_imports = set()
all_dlls = {}
for entry in win_pe.DIRECTORY_ENTRY_IMPORT:
dll = entry.dll.decode()
funcs = set()
for imp in entry.imports:
if imp.name:
funcs.add(imp.name.decode())
all_dlls[dll] = funcs
if dll.lower() == args.dll.lower():
win_imports = funcs
if not win_imports:
print(f"Warning: {driver_name} does not import from {args.dll}", file=sys.stderr)
print(f"Available import DLLs: {', '.join(all_dlls.keys())}")
sys.exit(1)
print(f"Imports from {args.dll}: {len(win_imports)}")
# Load ReactOS ntoskrnl exports
ros_exports = set()
if os.path.exists(args.ros_ntoskrnl):
try:
ros_pe = pefile.PE(args.ros_ntoskrnl)
if hasattr(ros_pe, 'DIRECTORY_ENTRY_EXPORT'):
for sym in ros_pe.DIRECTORY_ENTRY_EXPORT.symbols:
if sym.name:
ros_exports.add(sym.name.decode())
print(f"ReactOS exports: {len(ros_exports)}")
except Exception as e:
print(f"Warning: couldn't read {args.ros_ntoskrnl}: {e}", file=sys.stderr)
else:
print(f"Warning: {args.ros_ntoskrnl} not found, reading spec file instead")
# Fall back to parsing spec file
if os.path.exists(args.ros_spec):
with open(args.ros_spec) as f:
for line in f:
line = line.strip()
if line.startswith('@'):
parts = line.split()
for i, p in enumerate(parts):
if p in ('stdcall', 'cdecl', 'extern', 'stub'):
if i + 1 < len(parts):
name = parts[i + 1].split('(')[0]
# Strip -arch= prefix
if name.startswith('-'):
continue
ros_exports.add(name)
break
print(f"ReactOS spec entries: {len(ros_exports)}")
# Find missing functions
missing = win_imports - ros_exports
already = win_imports & ros_exports
print(f"\nAlready exported: {len(already)}")
print(f"Missing (need stubs): {len(missing)}")
if not missing:
print("\nAll imports are already satisfied!")
sys.exit(0)
# Also report other DLLs the driver needs
other_dlls = {k: v for k, v in all_dlls.items() if k.lower() != args.dll.lower()}
if other_dlls:
print(f"\nOther DLL dependencies:")
for dll, funcs in sorted(other_dlls.items()):
print(f" {dll}: {len(funcs)} functions")
# Group missing functions by subsystem
by_subsystem = defaultdict(list)
externs = []
for func in sorted(missing):
if func in KNOWN_EXTERNS:
externs.append(func)
else:
subdir, desc = get_subsystem(func)
by_subsystem[(subdir, desc)].append(func)
# Print summary
print(f"\nMissing functions by subsystem:")
for (subdir, desc), funcs in sorted(by_subsystem.items()):
print(f" {desc} ({subdir}/): {len(funcs)}")
if externs:
print(f" Extern globals: {len(externs)}")
if args.dry_run:
print("\n--- DRY RUN: would generate the following ---\n")
# Generate spec entries
spec_lines = []
spec_lines.append(f"\n# Stubs for {driver_name} compatibility")
for func in externs:
spec_lines.append(f"@ extern {func}")
for (subdir, desc), funcs in sorted(by_subsystem.items()):
spec_lines.append(f"# {desc}")
for func in funcs:
spec_args = get_spec_args(func)
if spec_args:
spec_lines.append(f"@ stdcall {func}({spec_args})")
else:
spec_lines.append(f"@ stdcall {func}()")
spec_text = '\n'.join(spec_lines) + '\n'
if args.dry_run:
print("=== SPEC ADDITIONS ===")
print(spec_text)
else:
# Append to spec file
with open(args.ros_spec, 'a') as f:
f.write(spec_text)
print(f"\nAppended {len(missing)} entries to {args.ros_spec}")
# Generate stub C files
cmake_additions = []
files_created = []
# Merge subsystems that map to the same directory
by_dir = defaultdict(list)
dir_descs = {}
for (subdir, desc), funcs in sorted(by_subsystem.items()):
by_dir[subdir].extend(funcs)
dir_descs.setdefault(subdir, []).append(desc)
for subdir, funcs in sorted(by_dir.items()):
funcs = sorted(set(funcs))
desc = ' / '.join(dir_descs[subdir])
stub_file = os.path.join(args.output_dir, subdir, 'stubs_generated.c')
cmake_path = f"${{REACTOS_SOURCE_DIR}}/ntoskrnl/{subdir}/stubs_generated.c"
content = []
content.append(f"/*")
content.append(f" * PROJECT: ReactOS Kernel")
content.append(f" * PURPOSE: Auto-generated stubs for {desc}")
content.append(f" * NOTE: Generated by generate_stubs.py from {driver_name}")
content.append(f" * These are minimal stubs to allow the driver to load.")
content.append(f" * Each function needs a proper implementation.")
content.append(f" */")
content.append(f"")
content.append(f"#include <ntoskrnl.h>")
content.append(f"#define NDEBUG")
content.append(f"#include <debug.h>")
content.append(f"")
for func in funcs:
ret_type = get_return_type(func)
content.append(generate_stub_c(func, ret_type))
file_content = '\n'.join(content)
if args.dry_run:
print(f"=== {stub_file} ===")
print(file_content)
print()
else:
os.makedirs(os.path.dirname(stub_file), exist_ok=True)
with open(stub_file, 'w') as f:
f.write(file_content)
files_created.append(stub_file)
print(f"Created {stub_file} ({len(funcs)} stubs)")
cmake_additions.append(cmake_path)
# Generate extern globals file
if externs:
extern_file = os.path.join(args.output_dir, 'ex', 'externs_generated.c')
cmake_path = f"${{REACTOS_SOURCE_DIR}}/ntoskrnl/ex/externs_generated.c"
content = []
content.append(f"/*")
content.append(f" * PROJECT: ReactOS Kernel")
content.append(f" * PURPOSE: Auto-generated extern globals for {driver_name}")
content.append(f" */")
content.append(f"")
content.append(f"#include <ntoskrnl.h>")
content.append(f"")
for func in sorted(externs):
if 'ObjectType' in func:
content.append(f"POBJECT_TYPE {func} = NULL;")
elif func == 'MmBadPointer':
content.append(f"PVOID {func} = NULL;")
elif func == 'InitializeSListHead':
content.append(f"/* InitializeSListHead is typically a macro/inline */")
content.append(f"VOID NTAPI InitializeSListHead(PSLIST_HEADER ListHead)")
content.append(f"{{")
content.append(f" RtlZeroMemory(ListHead, sizeof(SLIST_HEADER));")
content.append(f"}}")
else:
content.append(f"PVOID {func} = NULL; /* FIXME: unknown type */")
content.append(f"")
file_content = '\n'.join(content)
if args.dry_run:
print(f"=== {extern_file} ===")
print(file_content)
else:
os.makedirs(os.path.dirname(extern_file), exist_ok=True)
with open(extern_file, 'w') as f:
f.write(file_content)
files_created.append(extern_file)
print(f"Created {extern_file} ({len(externs)} externs)")
cmake_additions.append(cmake_path)
# Print cmake additions
print(f"\n=== Add to ntos.cmake (SOURCE list) ===")
for path in sorted(cmake_additions):
print(f" {path}")
if not args.dry_run and files_created:
print(f"\nGenerated {len(files_created)} files with {len(missing)} total stubs.")
print(f"Next steps:")
print(f" 1. Add the source files to ntos.cmake")
print(f" 2. Review and fix function signatures (grep for 'FIXME: args unknown')")
print(f" 3. Build and test: ninja ntoskrnl")
if __name__ == '__main__':
main()

IRP & Kernel Structure Layouts for GDB

Quick reference for inspecting IRPs, KEVENTs, and kernel objects in ReactOS AMD64 via GDB. For GDB setup and connection, see gdb-real-mode-debugging.md.


IRP Structure (AMD64)

From sdk/include/xdk/iotypes.h.

Offset  Size  Field                          Notes
──────  ────  ─────                          ─────
0x00    2     CSHORT Type                    IO_TYPE_IRP = 0x0006
0x02    2     USHORT Size
0x08    8     PMDL MdlAddress
0x10    4     ULONG Flags                    IRP_PAGING_IO=0x2, IRP_NOCACHE=0x1
0x18    8     union AssociatedIrp             SystemBuffer / MasterIrp
0x30    16    IO_STATUS_BLOCK IoStatus
0x48    8     PIO_STATUS_BLOCK UserIosb
0x50    8     PKEVENT UserEvent               ← event signaled on completion
0x60    8     PDRIVER_CANCEL CancelRoutine
0x68    8     PVOID UserBuffer
0x78    8     Tail.Overlay.Thread
x/hx $irp          # Type (0x0006)
x/wx $irp+0x10     # Flags
x/gx $irp+0x50     # UserEvent
x/gx $irp+0x68     # UserBuffer

KEVENT

Offset  Size  Field
──────  ────  ─────
0x00    4     DISPATCHER_HEADER (Type + packed fields)
0x04    4     SignalState          ← 0 = not signaled
0x08    16    WaitListHead
x/wx $event+4       # SignalState

IRP Completion Path (Paging I/O)

IofCompleteRequest(Irp)
  ├─ if PAGING_IO | CLOSE_OPERATION:
  │    ├─ *Irp->UserIosb = Irp->IoStatus
  │    ├─ KeSetEvent(Irp->UserEvent)
  │    └─ IoFreeIrp(Irp)
  └─ else: queue APC → IopCompleteRequest

After IoFreeIrp, the IRP memory may be reused by IoAllocateIrp — this is normal, not corruption.


Findings: 2026-04-04 IRP Hang Session

Root Cause

The hanging IRP was dispatched to SCSIPORT and never completed. UserEvent was intact (non-NULL, SignalState=0). IoCompleteRequest was never called for this IRP.

What we expected What we found
UserEvent zeroed UserEvent intact, SignalState=0
Event signaled but lost Event never signaled
IoCompleteRequest called wrong Never called for this IRP

IRP Memory Reuse is Normal

A hardware watchpoint on IRP+0x50 fired on RtlZeroMemory called from IoAllocateIrp. This was a NEW IRP allocation reusing the same pool address — the previous IRP had already completed normally.

Key Addresses

Address What
FFFFFA800167ACF0 The hanging IRP
FFFFF88074533570 KEVENT on kernel stack (NtfsReadDisk local)
FFFFF80000575A44 Near NTFS code (KSEG0)
FFFFF8000047A590 IoAllocateIrp
#!/usr/bin/env python3
"""
Lazy qcow2/MBR/VBR/NTFS parser — reads only what you ask for.
Usage:
from ntfs_parser import QCow2Image
img = QCow2Image("reactos.qcow2")
mbr = img.mbr()
for part in mbr.partitions():
print(f" {part}")
if part.is_ntfs():
vbr = part.vbr()
print(f" {vbr}")
root = vbr.root_directory()
for name, entry in root.entries():
print(f" {name}")
"""
import struct
import sys
from typing import Iterator, Optional, Tuple, Dict, List
def hex_dump(data: bytes, base: int = 0, width: int = 16) -> str:
"""Return a formatted hex dump of *data*."""
lines = []
for i in range(0, len(data), width):
chunk = data[i:i + width]
hex_part = ' '.join(f'{b:02x}' for b in chunk)
hex_part = hex_part.ljust(width * 3 - 1)
ascii_part = ''.join(chr(b) if 32 <= b < 127 else '.' for b in chunk)
lines.append(f' {base + i:08x}: {hex_part} {ascii_part}')
return '\n'.join(lines)
def apply_usa_fixup(data: bytes, sector_size: int = 512) -> bytes:
"""Apply NTFS Update Sequence Array (USA) fixup to a FILE or INDX record.
The USA protects against torn writes. Layout inside the record:
offset 0x04 (USHORT) - UsaOffset: byte offset to the USA within the record
offset 0x06 (USHORT) - UsaCount: number of USHORT entries in the USA
Entry[0] = expected value found at the end of each sector
Entry[i] (i >= 1) = original two bytes that belong at the end of sector i-1
For each protected sector the last two bytes must equal Entry[0]; they are
then replaced with Entry[i] to restore the original data.
Returns the fixed-up data (as bytes). Raises ValueError on a torn-write
mismatch so the caller can decide how to handle it.
"""
if len(data) < 8:
return data
usa_offset = struct.unpack_from('<H', data, 0x04)[0]
usa_count = struct.unpack_from('<H', data, 0x06)[0]
# Sanity: need at least 2 entries and the array must fit inside the record
if usa_count < 2 or usa_offset + usa_count * 2 > len(data):
return data
# Read the USA entries (array of USHORTs)
usa = []
for i in range(usa_count):
usa.append(struct.unpack_from('<H', data, usa_offset + i * 2)[0])
expected = usa[0]
buf = bytearray(data)
for i in range(1, usa_count):
pos = i * sector_size - 2 # last 2 bytes of sector (i-1)
if pos + 2 > len(buf):
break
actual = struct.unpack_from('<H', buf, pos)[0]
if actual != expected:
raise ValueError(
f"USA fixup mismatch at sector {i-1} (offset 0x{pos:x}): "
f"expected 0x{expected:04x}, got 0x{actual:04x} — possible torn write")
struct.pack_into('<H', buf, pos, usa[i])
return bytes(buf)
# ─── Lazy descriptor base ───────────────────────────────────────────────────
class _Lazy:
"""Descriptor that computes a value once on first access, then caches it."""
def __init__(self, func):
self._func = func
self._name = func.__name__
def __set_name__(self, owner, name):
self._name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
value = self._func(obj)
setattr(obj, self._name, value)
return value
# ─── Block image abstraction ───────────────────────────────────────────────
class BlockImage:
"""Abstract interface for reading 512-byte sectors from a disk image."""
def read_sector(self, sector: int) -> Optional[bytes]:
raise NotImplementedError
@property
def size(self) -> int:
raise NotImplementedError
@_Lazy
def mbr(self):
"""Return an MBR parser for sector 0, or None."""
data = self.read_sector(0)
if data is None:
return None
return MBR(data)
def close(self):
pass
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
class RawImage(BlockImage):
"""Raw disk image — sector N = file offset N*512."""
def __init__(self, path: str):
self._f = open(path, 'rb')
self._f.seek(0, 2)
self._size = self._f.tell()
self._f.seek(0)
@property
def size(self) -> int:
return self._size
def read_sector(self, sector: int) -> Optional[bytes]:
offset = sector * 512
if offset + 512 > self._size:
return None
self._f.seek(offset)
return self._f.read(512)
def close(self):
self._f.close()
class QCow2Image(BlockImage):
"""Lazily maps guest sectors → host file offsets. No data is read until
you explicitly ask for a sector via read_sector()."""
MAGIC = b'QFI\xfb'
def __init__(self, path: str):
self._f = open(path, 'rb')
self._f.seek(0)
# Read magic + header in one go (header offsets are from byte 0)
hdr = self._f.read(116)
magic = hdr[0:4]
if magic != self.MAGIC:
raise ValueError(f"Not a qcow2 image (got {magic!r})")
# ── Header (qcow2 v3 = 104 bytes minimum, we read 116 for v3 extensions) ──
# All offsets are from the start of the file (byte 0)
def _u32(off): return struct.unpack_from('>I', hdr, off)[0]
def _u64(off): return struct.unpack_from('>Q', hdr, off)[0]
self.version = _u32(4)
self.backing_file_offset = _u64(8)
self.backing_file_size = _u32(16)
self.cluster_bits = _u32(20)
self.virtual_size = _u64(24)
self.crypt_method = _u32(32)
self.l1_size = _u32(36)
self.l1_table_offset = _u64(40)
self.refcount_table_offset = _u64(48)
self.refcount_table_clusters = _u32(56)
self.nb_snapshots = _u32(60)
self.snapshots_offset = _u64(64)
self.incompatible_features = _u64(72)
self.compatible_features = _u64(80)
self.autoclear_features = _u64(88)
self.refcount_order = _u32(96)
self.header_length = _u32(100)
self.cluster_size = 1 << self.cluster_bits
self._l2_entries = self.cluster_size // 8 # 64-bit entries per L2 table
# ── L1 table (small, read once) ──
self._f.seek(self.l1_table_offset)
self._l1 = struct.unpack(
f'>{self.l1_size}Q',
self._f.read(self.l1_size * 8))
@property
def size(self) -> int:
return self.virtual_size
def read_sector(self, guest_sector: int) -> Optional[bytes]:
"""Return 512 bytes for the given guest LBA, or None if unallocated."""
sectors_per_cluster = self.cluster_size // 512
cluster_idx = guest_sector // sectors_per_cluster
cluster_off = guest_sector % sectors_per_cluster
l1_idx = cluster_idx // self._l2_entries
l2_idx = cluster_idx % self._l2_entries
if l1_idx >= self.l1_size:
return None
l1_entry = self._l1[l1_idx]
if not (l1_entry & 0x8000000000000000):
return None
# Read L2 table (lazy: only when needed)
l2_offset = l1_entry & 0x00FFFFFFFFFFFFFF
self._f.seek(l2_offset)
l2_data = self._f.read(self.cluster_size)
l2_entry = struct.unpack_from('>Q', l2_data, l2_idx * 8)[0]
if not (l2_entry & 0x8000000000000000):
return None
host_offset = l2_entry & 0x00FFFFFFFFFFFFFF
self._f.seek(host_offset + cluster_off * 512)
return self._f.read(512)
def allocated_clusters(self) -> Iterator[Tuple[int, int]]:
"""Yield (guest_sector, count) for each allocated cluster.
Walks L1/L2 on demand, yields as it goes."""
sectors_per_cluster = self.cluster_size // 512
for l1_idx in range(self.l1_size):
l1_entry = self._l1[l1_idx]
if not (l1_entry & 0x8000000000000000):
continue
l2_offset = l1_entry & 0x00FFFFFFFFFFFFFF
self._f.seek(l2_offset)
l2_data = self._f.read(self.cluster_size)
for l2_idx in range(self._l2_entries):
l2_entry = struct.unpack_from('>Q', l2_data, l2_idx * 8)[0]
if l2_entry & 0x8000000000000000:
guest = (l1_idx * self._l2_entries + l2_idx) * sectors_per_cluster
yield guest, sectors_per_cluster
def close(self):
self._f.close()
# ─── MBR ────────────────────────────────────────────────────────────────────
class MBR:
"""Master Boot Record (sector 0)."""
def __init__(self, data: bytes):
self._data = data
@property
def signature(self) -> int:
return struct.unpack_from('<H', self._data, 510)[0]
@property
def is_valid(self) -> bool:
return self.signature == 0xAA55
def partitions(self) -> Iterator['PartitionEntry']:
"""Yield the 4 primary partition entries."""
for i in range(4):
off = 0x1BE + i * 16
boot, chs_start, type_, chs_end, lba, sectors = struct.unpack_from(
'<B3sB3sII', self._data, off)
yield PartitionEntry(i, boot, type_, lba, sectors)
class PartitionEntry:
def __init__(self, index: int, boot: int, type_: int, lba: int, sectors: int):
self.index = index
self.boot = boot
self.type = type_
self.lba = lba
self.sectors = sectors
@property
def size_bytes(self) -> int:
return self.sectors * 512
@property
def size_mb(self) -> float:
return self.sectors * 512 / (1024 ** 2)
def is_ntfs(self) -> bool:
return self.type in (0x07, 0x27) # HPFS/NTFS, hidden NTFS
def is_fat32(self) -> bool:
return self.type in (0x0B, 0x0C)
def is_active(self) -> bool:
return self.boot == 0x80
def vbr(self, img: QCow2Image) -> Optional['NTFSVBR']:
"""Read the Volume Boot Record for this partition."""
if self.lba == 0:
return None
data = img.read_sector(self.lba)
if data is None:
return None
if data[3:11] == b'NTFS ':
return NTFSVBR(data, self, img)
return None
def __repr__(self):
return (f"Partition({self.index}: type=0x{self.type:02x} "
f"lba={self.lba} sectors={self.sectors} "
f"{'ACTIVE ' if self.is_active() else ''}"
f"{self.size_mb:.0f}MB)")
# ─── NTFS VBR ───────────────────────────────────────────────────────────────
class NTFSVBR:
"""NTFS Volume Boot Record. All fields are lazily extracted."""
def __init__(self, data: bytes, partition: PartitionEntry, img: QCow2Image):
self._data = data
self._partition = partition
self._img = img
# ── Raw VBR fields ──
@property
def oem_id(self) -> bytes:
return self._data[3:11]
@property
def bytes_per_sector(self) -> int:
return struct.unpack_from('<H', self._data, 11)[0]
@property
def sectors_per_cluster(self) -> int:
return self._data[13]
@property
def total_sectors(self) -> int:
return struct.unpack_from('<Q', self._data, 0x28)[0]
@property
def mft_cluster(self) -> int:
return struct.unpack_from('<Q', self._data, 0x30)[0]
@property
def mft_mirror_cluster(self) -> int:
return struct.unpack_from('<Q', self._data, 0x38)[0]
@property
def clusters_per_mft_record(self) -> int:
"""Bytes per MFT record. <128 = power of 2, >=128 = negative exponent."""
b = self._data[0x40]
if b < 128:
return 1 << b
# Negative exponent: record size = cluster_size / 2^(b - 128)
shift = b - 128
result = (self.sectors_per_cluster * 512) >> shift
# Sanity check: MFT records are typically 1024 bytes
if result < 256 or result > 65536:
return 1024 # Default fallback for corrupted VBR
return result
@property
def clusters_per_index_record(self) -> int:
"""Bytes per index record. <128 = power of 2, >=128 = negative exponent."""
b = self._data[0x44]
if b < 128:
return 1 << b
shift = b - 128
result = (self.sectors_per_cluster * 512) >> shift
if result < 256 or result > 65536:
return 4096 # Default fallback for corrupted VBR
return result
@property
def volume_serial(self) -> int:
return struct.unpack_from('<Q', self._data, 0x48)[0]
@property
def checksum(self) -> int:
return struct.unpack_from('<I', self._data, 0x50)[0]
# ── Derived ──
@property
def mft_sector(self) -> int:
"""Absolute guest LBA of the MFT (cluster 0 of the partition)."""
return self._partition.lba + self.mft_cluster * self.sectors_per_cluster
@property
def partition_start(self) -> int:
"""Absolute guest LBA where this partition begins."""
return self._partition.lba
@property
def mft_record_size(self) -> int:
return self.clusters_per_mft_record
@property
def is_valid(self) -> bool:
"""Basic sanity checks."""
if self.oem_id != b'NTFS ':
return False
if self.bytes_per_sector not in (512, 1024, 2048, 4096):
return False
if self.sectors_per_cluster not in (1, 2, 4, 8, 16, 32, 64, 128):
return False
if self.total_sectors == 0:
return False
# MFT cluster should be within partition bounds
max_cluster = self.total_sectors // self.sectors_per_cluster
if self.mft_cluster >= max_cluster:
return False
return True
def read_sector(self, partition_relative: int) -> Optional[bytes]:
"""Read a sector relative to the start of this partition."""
return self._img.read_sector(self._partition.lba + partition_relative)
def read_absolute_sector(self, absolute_sector: int) -> Optional[bytes]:
"""Read a sector by absolute guest LBA (bypasses partition offset)."""
return self._img.read_sector(absolute_sector)
# ── MFT access ──
def _get_mft_runlist(self):
"""Get the runlist for the $MFT file's $DATA attribute.
Reads MFT record 0 using the VBR's mft_cluster (which is always
correct for record 0), then extracts the $DATA runlist.
Returns list of (vcn, lcn, num_clusters) or None.
"""
if hasattr(self, '_mft_runlist'):
return self._mft_runlist
# Read MFT record 0 using VBR location (record 0 is always at VBR location)
record_size = self.mft_record_size
sectors_per_record = record_size // 512
if sectors_per_record < 1:
return None
mft_start = self.mft_cluster * self.sectors_per_cluster
data = self.read_sector(mft_start)
if data is None:
return None
if sectors_per_record > 1:
chunks = [data]
for i in range(1, sectors_per_record):
chunk = self.read_sector(mft_start + i)
if chunk is None:
return None
chunks.append(chunk)
data = b''.join(chunks)
mft0 = MFTRecord(data[:record_size], self)
if not mft0.is_valid:
return None
data_attr = mft0.attribute(0x80) # $DATA
if data_attr is None:
self._mft_runlist = []
return []
if data_attr.resident:
# Contiguous MFT — no runlist needed
self._mft_runlist = []
return []
self._mft_runlist = data_attr.runlist
return data_attr.runlist
def _read_mft_record_bytes(self, record_number: int) -> Optional[bytes]:
"""Read the raw bytes for an MFT record, following the MFT's $DATA runlist
if the MFT is fragmented.
"""
record_size = self.mft_record_size
sectors_per_record = record_size // 512
if sectors_per_record < 1:
return None
runlist = self._get_mft_runlist()
if not runlist:
# Contiguous MFT — use VBR location
mft_start = self.mft_cluster * self.sectors_per_cluster
sector_offset = record_number * sectors_per_record
target_sector = mft_start + sector_offset
data = self.read_sector(target_sector)
if data is None:
return None
if sectors_per_record > 1:
chunks = [data]
for i in range(1, sectors_per_record):
chunk = self.read_sector(target_sector + i)
if chunk is None:
return None
chunks.append(chunk)
data = b''.join(chunks)
return data[:record_size]
# Fragmented MFT — find which run contains this record
target_byte = record_number * record_size
cluster_size = self.sectors_per_cluster * 512
for vcn, lcn, num_clusters in runlist:
cluster_start_byte = vcn * cluster_size
cluster_end_byte = cluster_start_byte + num_clusters * cluster_size
if cluster_start_byte <= target_byte < cluster_end_byte:
# Found the right cluster
offset_in_cluster = target_byte - cluster_start_byte
abs_sector = self.partition_start + lcn * self.sectors_per_cluster + (offset_in_cluster // 512)
sector_offset_in_record = (offset_in_cluster % 512) // 512
data = self.read_absolute_sector(abs_sector)
if data is None:
return None
if sectors_per_record > 1:
chunks = [data]
for i in range(1, sectors_per_record):
chunk = self.read_absolute_sector(abs_sector + i)
if chunk is None:
return None
chunks.append(chunk)
data = b''.join(chunks)
return data[:record_size]
# Record not found in any run — might be beyond the MFT data
return None
def mft_record(self, record_number: int) -> Optional['MFTRecord']:
"""Return the MFT record for the given record number, or None.
Handles both contiguous and fragmented MFTs.
"""
data = self._read_mft_record_bytes(record_number)
if data is None:
return None
return MFTRecord(data, self)
def mft_record_at(self, absolute_sector: int, record_number: int) -> Optional['MFTRecord']:
"""Read an MFT record at a known-good absolute sector (bypasses corrupted VBR)."""
record_size = self.mft_record_size
sectors_per_record = record_size // 512
if sectors_per_record < 1:
return None
sector_offset = record_number * sectors_per_record
target = absolute_sector + sector_offset
data = self.read_absolute_sector(target)
if data is None:
return None
if sectors_per_record > 1:
chunks = [data]
for i in range(1, sectors_per_record):
chunk = self.read_absolute_sector(target + i)
if chunk is None:
return None
chunks.append(chunk)
data = b''.join(chunks)
return MFTRecord(data[:record_size], self)
def find_mft_by_scan(self, start_sector: int = 0, max_sectors: int = 200000) -> Optional[int]:
"""Scan for the $MFT FILE record (record 0) by signature.
Returns the absolute sector of MFT record 0, or None.
Strategy: look for two consecutive 1024-byte FILE records 512 sectors apart
(MFT records are typically 1024 bytes = 2 sectors, record 0 and 1).
"""
# Scan every sector looking for FILE signature
for sec in range(start_sector, min(max_sectors, self._partition.lba + self.total_sectors)):
data = self._img.read_sector(sec)
if not data or data[:4] != b'FILE':
continue
# Verify it has a valid attribute offset
try:
attr_off = struct.unpack_from('<H', data, 0x14)[0]
if attr_off < 0x30 or attr_off > 0x200:
continue
except Exception:
continue
# Check if the next sector also has FILE (MFT records span 2 sectors typically)
# Or check if sector+2 also has FILE (record 1 at sec+2)
next_data = self._img.read_sector(sec + 2)
if next_data and next_data[:4] == b'FILE':
return sec
# Single FILE record found — might still be MFT if it has $DATA attribute
# For simplicity, accept it if attr_off looks valid
return sec
return None
def root_directory(self) -> Optional['MFTRecord']:
"""Return MFT record 5 (the root directory '\\')."""
return self.mft_record(5)
def root_directory_at(self, mft_sector: int) -> Optional['MFTRecord']:
"""Return MFT record 5 using a known-good MFT location."""
return self.mft_record_at(mft_sector, 5)
def __repr__(self):
valid = "VALID" if self.is_valid else "CORRUPTED"
return (f"NTFSVBR({valid}: oem={self.oem_id!r} "
f"bps={self.bytes_per_sector} spc={self.sectors_per_cluster} "
f"total_sectors={self.total_sectors} "
f"mft_cluster={self.mft_cluster} "
f"mft_record_size={self.mft_record_size})")
# ─── MFT Record ─────────────────────────────────────────────────────────────
class MFTRecord:
"""A single MFT record. Attributes are parsed lazily."""
# Well-known record numbers
RECORD_MFT = 0
RECORD_MFTMIRR = 1
RECORD_LOGFILE = 2
RECORD_VOLUME = 3
RECORD_ATTRDEF = 4
RECORD_ROOT = 5
RECORD_BITMAP = 6
RECORD_BOOT = 7
RECORD_BADCLUS = 8
RECORD_SECURE = 9
RECORD_UPCASE = 10
RECORD_EXTEND = 11
NAMES = {
0: '$MFT', 1: '$MFTMirr', 2: '$LogFile', 3: '$Volume',
4: '$AttrDef', 5: '', 6: '$Bitmap', 7: '$Boot',
8: '$BadClus', 9: '$Secure', 10: '$UpCase', 11: '$Extend',
}
ATTR_STANDARD_INFO = 0x10
ATTR_FILE_NAME = 0x30
ATTR_DATA = 0x80
ATTR_INDEX_ROOT = 0x90
ATTR_INDEX_ALLOCATION = 0xA0
ATTR_BITMAP = 0xB0
ATTR_NAMES = {
0x10: '$STANDARD_INFORMATION',
0x20: '$ATTRIBUTE_LIST',
0x30: '$FILE_NAME',
0x40: '$OBJECT_ID',
0x50: '$SECURITY_DESCRIPTOR',
0x60: '$VOLUME_NAME',
0x70: '$VOLUME_INFORMATION',
0x80: '$DATA',
0x90: '$INDEX_ROOT',
0xA0: '$INDEX_ALLOCATION',
0xB0: '$BITMAP',
0xC0: '$REPARSE_POINT',
}
def __init__(self, data: bytes, vbr: NTFSVBR):
# Apply USA fixup before any field parsing
if len(data) >= 4 and data[:4] in (b'FILE', b'BADB'):
try:
data = apply_usa_fixup(data)
except ValueError:
pass # Torn write — keep original data, parsing may fail
self._data = data
self._vbr = vbr
self._attributes: Optional[List['_Attribute']] = None
@property
def signature(self) -> bytes:
return self._data[0:4]
@property
def is_valid(self) -> bool:
return self.signature in (b'FILE', b'BADB')
@property
def is_deleted(self) -> bool:
return self.signature == b'BADB'
@property
def base_record(self) -> int:
"""MFT reference of the base record (0 if this is the base)."""
ref = struct.unpack_from('<Q', self._data, 0x18)[0]
return ref & 0xFFFFFFFFFFFF # lower 48 bits = record number
@property
def attribute_offset(self) -> int:
return struct.unpack_from('<H', self._data, 0x14)[0]
@property
def real_size(self) -> int:
return struct.unpack_from('<I', self._data, 0x18)[0]
@property
def allocated_size(self) -> int:
return struct.unpack_from('<I', self._data, 0x1C)[0]
@property
def flags(self) -> int:
return struct.unpack_from('<H', self._data, 0x16)[0]
@property
def is_directory(self) -> bool:
return bool(self.flags & 0x0002)
@property
def is_file(self) -> bool:
return not self.is_directory and not self.is_deleted
# ── Attribute parsing (lazy, streamed) ──
def _parse_attributes(self) -> List['_Attribute']:
"""Parse all attributes from the record data."""
attrs = []
pos = self.attribute_offset
limit = min(self.real_size, len(self._data))
while pos < limit - 8:
attr_type = struct.unpack_from('<I', self._data, pos)[0]
attr_len = struct.unpack_from('<I', self._data, pos + 4)[0]
if attr_type == 0xFFFFFFFF or attr_len == 0 or attr_len > limit:
break
non_resident = bool(self._data[pos + 8] & 0x01)
name_len = self._data[pos + 9]
name_off = struct.unpack_from('<H', self._data, pos + 10)[0]
attr_name = None
# name_off is relative to the start of this attribute (pos)
if name_len > 0 and name_off > 0 and pos + name_off + name_len * 2 <= limit:
try:
attr_name = self._data[pos + name_off:pos + name_off + name_len * 2].decode('utf-16-le')
except Exception:
pass
if non_resident:
# Non-resident attribute layout (relative to attribute start):
# 0x00: Attribute type (4)
# 0x04: Length (4)
# 0x08: Non-resident flag (1)
# 0x09: Name length (1)
# 0x0A: Name offset (2)
# 0x0C: Flags (2) — compression/encryption
# 0x0E: Instance (2)
# 0x10: Starting/Lowest VCN (8)
# 0x18: Ending/Highest VCN (8)
# 0x20: Runlist/MappingPairs offset (2)
# 0x22: Compression unit (2)
# 0x24: Reserved (4)
# 0x28: Allocated size (8)
# 0x30: Data size (8)
# 0x38: Initialized size (8)
# 0x40: Compressed size (8, only if compressed)
runlist_off = struct.unpack_from('<H', self._data, pos + 0x20)[0]
starting_vcn = struct.unpack_from('<Q', self._data, pos + 0x10)[0]
ending_vcn = struct.unpack_from('<Q', self._data, pos + 0x18)[0]
data_size = struct.unpack_from('<Q', self._data, pos + 0x30)[0]
runlist = self._parse_runlist(self._data, pos + runlist_off, pos + attr_len)
attr = _Attribute(
type_=attr_type, name=attr_name, resident=False,
runlist_offset=runlist_off,
starting_vcn=starting_vcn, clusters=ending_vcn - starting_vcn + 1,
data=None, data_offset=None, data_length=data_size,
runlist=runlist)
else:
content_off = struct.unpack_from('<H', self._data, pos + 0x14)[0]
content_len = struct.unpack_from('<I', self._data, pos + 0x10)[0]
attr = _Attribute(
type_=attr_type, name=attr_name, resident=True,
runlist_offset=None,
starting_vcn=None, clusters=None,
data=self._data[pos + content_off:pos + content_off + content_len],
data_offset=content_off, data_length=content_len,
runlist=None)
attrs.append(attr)
pos += attr_len
self._attributes = attrs
return attrs
@staticmethod
def _parse_runlist(data: bytes, offset: int, attr_end: int) -> List[Tuple[int, int, int]]:
"""Parse NTFS runlist. Returns list of (vcn, lcn, length) tuples.
vcn = virtual cluster number (relative to attribute start)
lcn = logical cluster number (absolute on disk)
length = number of clusters in this run
"""
runs = []
pos = offset
vcn = 0
prev_lcn = 0
while pos < attr_end:
header = data[pos]
if header == 0:
break # End of runlist
pos += 1
len_size = header & 0x0F
off_size = (header >> 4) & 0x0F
if pos + len_size + off_size > attr_end:
break
# Read length
length = 0
for i in range(len_size):
length |= data[pos + i] << (i * 8)
pos += len_size
# Read offset (signed, relative to previous LCN)
off_bytes = data[pos:pos + off_size]
offset_val = 0
for i in range(off_size):
offset_val |= off_bytes[i] << (i * 8)
# Sign extend
if off_size > 0 and off_bytes[-1] & 0x80:
offset_val -= (1 << (off_size * 8))
pos += off_size
lcn = prev_lcn + offset_val
runs.append((vcn, lcn, length))
vcn += length
prev_lcn = lcn
return runs
def attributes(self) -> Iterator['_Attribute']:
"""Iterate over all attributes in this record."""
if self._attributes is None:
self._parse_attributes()
yield from self._attributes
def attribute(self, type_: int, name: Optional[str] = None) -> Optional['_Attribute']:
"""Return the first attribute of the given type (optionally named)."""
for attr in self.attributes():
if attr.type == type_:
if name is None or attr.name == name:
return attr
return None
# ── Filename helpers ──
def filenames(self) -> Iterator[Tuple[str, int]]:
"""Yield (filename, namespace) for all $FILE_NAME attributes."""
for attr in self.attributes():
if attr.type == self.ATTR_FILE_NAME and attr.resident and attr.data:
data = attr.data
name_len = data[0x40]
name_ns = data[0x41]
if 0 < name_len < 256 and len(data) >= 0x42 + name_len * 2:
try:
name = data[0x42:0x42 + name_len * 2].decode('utf-16-le')
yield name, name_ns
except Exception:
pass
@property
def name(self) -> Optional[str]:
"""Return the first long filename, or short filename, or None."""
long_name = None
for name, ns in self.filenames():
if ns == 0x01: # Win32
return name
if ns == 0x03 and long_name is None: # Win32+DOS
long_name = name
if ns == 0x02 and long_name is None: # DOS
pass # prefer long names
return long_name
# ── Directory listing ──
def entries(self) -> Iterator[Tuple[str, 'IndexEntry']]:
"""Yield (filename, IndexEntry) for directory entries.
Works for both resident $INDEX_ROOT and non-resident $INDEX_ALLOCATION."""
# Try $INDEX_ROOT first (resident)
idx_root = self.attribute(self.ATTR_INDEX_ROOT)
if idx_root and idx_root.resident and idx_root.data:
yield from self._parse_index_data(idx_root.data)
# Also try $INDEX_ALLOCATION (non-resident) — follow runlist
idx_alloc = self.attribute(self.ATTR_INDEX_ALLOCATION)
if idx_alloc and not idx_alloc.resident and idx_alloc.runlist:
yield from self._parse_index_allocation(idx_alloc)
def _parse_index_allocation(self, attr: '_Attribute') -> Iterator[Tuple[str, 'IndexEntry']]:
"""Parse $INDEX_ALLOCATION by following the runlist and reading INDX blocks."""
if not attr.runlist:
return
cluster_size = self._vbr.sectors_per_cluster * 512
idx_record_size = 4096 # Typically 4KB index records
for vcn, lcn, num_clusters in attr.runlist:
for i in range(num_clusters):
# Calculate absolute sector for this cluster
abs_cluster = lcn + i
start_sector = self._vbr.partition_start + abs_cluster * self._vbr.sectors_per_cluster
# Read the entire index record (may span multiple clusters)
record_bytes = bytearray(idx_record_size)
bytes_read = 0
for s in range(self._vbr.sectors_per_cluster):
if bytes_read + 512 > idx_record_size:
break
sec_data = self._vbr._img.read_sector(start_sector + s)
if sec_data:
copy_len = min(512, idx_record_size - bytes_read)
record_bytes[bytes_read:bytes_read + copy_len] = sec_data[:copy_len]
bytes_read += 512
# Check for INDX signature
if record_bytes[:4] != b'INDX':
continue
# Apply USA fixup to the INDX block
try:
record_bytes = bytearray(apply_usa_fixup(bytes(record_bytes)))
except ValueError:
continue # Torn write — skip this INDX block
# Parse index block header
# 0x18: first entry offset (relative to this field)
# 0x1C: total size of entries
entry_off = struct.unpack_from('<I', record_bytes, 0x18)[0]
total_size = struct.unpack_from('<I', record_bytes, 0x1C)[0]
if total_size == 0 or total_size > idx_record_size:
continue
start = 0x18 + entry_off
end = start + total_size
pos = start
while pos < min(end, len(record_bytes)) - 0x52:
# Index entry header:
# +0x00: MFT reference (8 bytes)
# +0x08: Entry length (2 bytes)
# +0x0A: Key length (2 bytes)
# +0x0C: Flags (2 bytes)
entry_len = struct.unpack_from('<H', record_bytes, pos + 8)[0]
if entry_len == 0 or entry_len > 0x1000:
break
flags = struct.unpack_from('<H', record_bytes, pos + 0xC)[0]
fn_off = pos + 0x10
if fn_off + 0x42 < len(record_bytes):
name_len = record_bytes[fn_off + 0x40]
name_ns = record_bytes[fn_off + 0x41]
name_data = fn_off + 0x42
if 0 < name_len < 256 and name_data + name_len * 2 <= len(record_bytes):
try:
name = record_bytes[name_data:name_data + name_len * 2].decode('utf-16-le')
mft_ref = struct.unpack_from('<Q', record_bytes, pos)[0] & 0xFFFFFFFFFFFF
if name not in ('.', '..'):
yield name, IndexEntry(mft_ref, name_ns, flags)
except Exception:
pass
pos += entry_len
if flags & 0x02:
break
def _parse_index_data(self, data: bytes) -> Iterator[Tuple[str, 'IndexEntry']]:
"""Parse index entries from $INDEX_ROOT or $INDEX_ALLOCATION data.
The $INDEX_ROOT attribute layout:
0x00-0x03: indexed attribute type (0x30 for FILE_NAME)
0x04-0x07: collation rule
0x08-0x0B: index record size
0x0C-0x0F: clusters per index record
0x10-0x13: first entry offset (relative to this field, i.e. 0x10)
0x14-0x17: total size of entries
0x18-0x1B: allocated size of entries
"""
if len(data) < 0x18:
return
# Index header
entry_off = struct.unpack_from('<I', data, 0x10)[0]
total_size = struct.unpack_from('<I', data, 0x14)[0]
start = 0x10 + entry_off
end = start + total_size
pos = start
while pos < min(end, len(data)) - 0x52:
# Index entry header:
# +0x00: MFT reference (8 bytes)
# +0x08: Entry length (2 bytes)
# +0x0A: Key length (2 bytes)
# +0x0C: Flags (2 bytes)
entry_len = struct.unpack_from('<H', data, pos + 8)[0]
if entry_len == 0 or entry_len > 0x1000:
break
flags = struct.unpack_from('<H', data, pos + 0xC)[0]
# FILE_NAME attribute within entry (offset 0x10 from entry start)
fn_off = pos + 0x10
if fn_off + 0x42 < len(data):
name_len = data[fn_off + 0x40]
name_ns = data[fn_off + 0x41]
name_data = fn_off + 0x42
if 0 < name_len < 256 and name_data + name_len * 2 <= len(data):
try:
name = data[name_data:name_data + name_len * 2].decode('utf-16-le')
mft_ref = struct.unpack_from('<Q', data, pos)[0] & 0xFFFFFFFFFFFF
if name not in ('.', '..'):
yield name, IndexEntry(mft_ref, name_ns, flags)
except Exception:
pass
pos += entry_len
# Last entry has flag 0x02 (INDEX_ENTRY_END)
if flags & 0x02:
break
def __repr__(self):
if self.is_deleted:
return "MFTRecord(DELETED)"
name = self.name or f"<record {self._data[0x2C:0x30]}>"
kind = "DIR" if self.is_directory else "FILE"
return f"MFTRecord({kind}: {name!r})"
class IndexEntry:
def __init__(self, mft_ref: int, namespace: int, flags: int):
self.mft_ref = mft_ref
self.namespace = namespace
self.flags = flags
@property
def is_end(self) -> bool:
return bool(self.flags & 0x02) # INDEX_ENTRY_END
@property
def namespace_name(self) -> str:
return {0: 'POSIX', 1: 'Win32', 2: 'DOS', 3: 'Win32+DOS'}.get(
self.namespace, f'0x{self.namespace:x}')
class _Attribute:
"""Represents a single MFT attribute. Created during attribute parsing."""
__slots__ = ('type', 'name', 'resident', 'runlist_offset',
'starting_vcn', 'clusters', 'data', 'data_offset', 'data_length',
'runlist')
def __init__(self, type_, name, resident, runlist_offset,
starting_vcn, clusters, data, data_offset, data_length, runlist=None):
self.type = type_
self.name = name
self.resident = resident
self.runlist_offset = runlist_offset
self.starting_vcn = starting_vcn
self.clusters = clusters
self.data = data
self.data_offset = data_offset
self.data_length = data_length
self.runlist = runlist # List of (starting_vcn, lcn, cluster_count) tuples
@property
def type_name(self) -> str:
return MFTRecord.ATTR_NAMES.get(self.type, f'0x{self.type:x}')
def __repr__(self):
r = 'resident' if self.resident else 'non-resident'
return f"Attr({self.type_name} {r} name={self.name!r})"
# ─── CLI ────────────────────────────────────────────────────────────────────
def _main():
import argparse
parser = argparse.ArgumentParser(description='Lazy qcow2/NTFS parser')
parser.add_argument('image', help='Path to qcow2 image')
parser.add_argument('--list', action='store_true', help='List files in root directory')
parser.add_argument('--vbr', action='store_true', help='Show VBR details')
parser.add_argument('--allocated', action='store_true', help='Show allocated cluster count')
parser.add_argument('--mft', type=int, help='Show MFT record by number')
parser.add_argument('--scan', action='store_true', help='Scan for MFT by FILE signature')
parser.add_argument('--list-at', type=int, metavar='SECTOR',
help='List root directory using MFT at given absolute sector')
parser.add_argument('--hex', type=str, metavar='SECTOR',
help='Hex dump a sector by absolute LBA')
parser.add_argument('--mft-at', type=int, nargs=2, metavar=('SECTOR', 'RECORD'),
help='Show MFT record at given sector and record number')
args = parser.parse_args()
# Auto-detect image format
with open(args.image, 'rb') as f:
magic = f.read(4)
if magic == QCow2Image.MAGIC:
img: BlockImage = QCow2Image(args.image)
img_type = f"QCow2 v{img.version}"
img_info = f"{img.virtual_size / 1024**3:.1f} GB (cluster={img.cluster_size}B, L1={img.l1_size})"
else:
img = RawImage(args.image)
img_type = "Raw"
img_info = f"{img.size / 1024**3:.1f} GB"
with img:
print(f"{img_type}: {img_info}")
# Handle --hex at top level (works for any sector)
if args.hex is not None:
sec = int(args.hex, 0)
data = img.read_sector(sec)
if data:
print(f"\nSector {sec} (0x{sec:x}):")
print(hex_dump(data))
else:
print(f"\nSector {sec} is unallocated")
return # Exit after hex dump
mbr = img.mbr
if mbr and mbr.is_valid:
print(f"\nMBR: valid (sig=0x{mbr.signature:04x})")
has_partitions = False
for part in mbr.partitions():
if part.sectors > 0:
has_partitions = True
print(f" {part}")
if part.is_ntfs():
vbr = part.vbr(img)
if vbr:
_process_vbr(vbr, args, img)
else:
print(f" {part}")
if not has_partitions:
print(" (no partitions — trying raw NTFS volume)")
_try_raw_ntfs(img, args)
else:
_try_raw_ntfs(img, args)
def _try_raw_ntfs(img: BlockImage, args):
"""Try to parse the entire image as a raw NTFS volume (no MBR)."""
data = img.read_sector(0)
if data and data[3:11] == b'NTFS ':
print("\nRaw NTFS volume detected at sector 0")
# Create a fake partition entry for the whole image
total_sectors = img.size // 512
fake_part = PartitionEntry(0, 0x00, 0x07, 0, total_sectors)
vbr = NTFSVBR(data, fake_part, img)
_process_vbr(vbr, args, img)
else:
print("MBR: invalid or not found, and no raw NTFS detected")
def _process_vbr(vbr: NTFSVBR, args, img: BlockImage):
"""Process a VBR with all the CLI options."""
print(f" {vbr}")
if not vbr.is_valid:
print(" WARNING: VBR appears corrupted!")
max_cluster = vbr.total_sectors // vbr.sectors_per_cluster if vbr.sectors_per_cluster else 0
print(f" MFT cluster {vbr.mft_cluster} exceeds partition max {max_cluster}")
if args.vbr:
print(f"\n VBR fields:")
print(f" OEM: {vbr.oem_id!r}")
print(f" Bytes/sector: {vbr.bytes_per_sector}")
print(f" Sectors/cluster: {vbr.sectors_per_cluster}")
print(f" Total sectors: {vbr.total_sectors}")
print(f" MFT cluster: {vbr.mft_cluster}")
print(f" MFT mirror cluster: {vbr.mft_mirror_cluster}")
print(f" MFT record size: {vbr.mft_record_size} bytes")
print(f" Index record size: {vbr.clusters_per_index_record} bytes")
print(f" Volume serial: 0x{vbr.volume_serial:016x}")
print(f" Checksum: 0x{vbr.checksum:08x}")
if args.allocated:
if hasattr(img, 'allocated_clusters'):
count = sum(1 for _ in img.allocated_clusters())
print(f"\n Allocated clusters: {count} "
f"({count * img.cluster_size / 1024**2:.1f} MB)")
else:
print(f"\n (allocated clusters only available for qcow2 images)")
if args.list:
root = vbr.root_directory()
if root:
print(f"\n Root directory ({root}):")
entries = list(root.entries())
if entries:
for name, entry in sorted(entries, key=lambda x: x[0].lower()):
print(f" {name} [{entry.namespace_name}]")
else:
print(" (no entries found — directory may be too large "
"or $INDEX_ALLOCATION not yet implemented)")
else:
print(" Could not read root directory (MFT record 5)")
if args.mft is not None:
rec = vbr.mft_record(args.mft)
if rec:
print(f"\n MFT record {args.mft}: {rec}")
print(f" Valid: {rec.is_valid}")
print(f" Deleted: {rec.is_deleted}")
print(f" Flags: 0x{rec.flags:x}")
print(f" Attributes:")
for attr in rec.attributes():
print(f" {attr}")
else:
print(f" Could not read MFT record {args.mft} "
f"(VBR mft_cluster={vbr.mft_cluster} may be corrupted)")
if args.scan:
print(f"\n Scanning for MFT...")
start = vbr._partition.lba if hasattr(vbr._partition, 'lba') else 0
found = vbr.find_mft_by_scan(start_sector=start,
max_sectors=start + 200000)
if found:
vbr_mft_sector = vbr.mft_cluster * vbr.sectors_per_cluster + (vbr._partition.lba if hasattr(vbr._partition, 'lba') else 0)
print(f" MFT found at absolute sector {found}")
print(f" (VBR says {vbr_mft_sector})")
root = vbr.root_directory_at(found)
if root:
print(f" Root directory at this MFT: {root}")
entries = list(root.entries())
if entries:
print(f" Files in root:")
for name, entry in sorted(entries, key=lambda x: x[0].lower()):
print(f" {name} [{entry.namespace_name}]")
else:
print(f" (no directory entries found)")
else:
print(f" Could not read root directory at MFT sector {found}")
else:
print(f" MFT not found in scan range")
if args.list_at is not None:
root = vbr.root_directory_at(args.list_at)
if root:
print(f"\n Root directory at MFT sector {args.list_at}: {root}")
entries = list(root.entries())
if entries:
for name, entry in sorted(entries, key=lambda x: x[0].lower()):
print(f" {name} [{entry.namespace_name}]")
else:
print(f" (no entries found)")
else:
print(f" Could not read root directory at MFT sector {args.list_at}")
if args.mft_at is not None:
sec, rec = args.mft_at
mft_rec = vbr.mft_record_at(sec, rec)
if mft_rec:
print(f"\n MFT record {rec} at sector {sec}: {mft_rec}")
print(f" Valid: {mft_rec.is_valid}")
print(f" Deleted: {mft_rec.is_deleted}")
print(f" Directory: {mft_rec.is_directory}")
print(f" Flags: 0x{mft_rec.flags:x}")
print(f" Names: {list(mft_rec.filenames())}")
print(f" Attributes:")
for attr in mft_rec.attributes():
print(f" {attr}")
if attr.resident and attr.data:
print(f" Data ({len(attr.data)} bytes):")
print(hex_dump(attr.data))
else:
print(f" Could not read MFT record {rec} at sector {sec}")
if __name__ == '__main__':
_main()

Pilotty + QEMU Tips for ReactOS

Pilotty-specific notes. For the full install automation, use utils.sh (ros_install_ntfs). For GDB debugging, see gdb-real-mode-debugging.md.

Filtered Serial Watcher

The raw serial log is flooded with SCSIPORT noise (~100K lines/min). Filter it:

npx pilotty spawn --name serial -- bash -c \
  "tail -f /tmp/rosN.log | grep --line-buffered -E \
  'SETUP_PAGE:|SETUP_SELECTED:|PROGRESS:|COPY_FILE:|NtfsReadDisk: waiting|kdb:>'"

Buffer Limitation

Pilotty keeps a fixed terminal scrollback. wait-for only sees text currently in the buffer. If output arrives faster than you consume it, markers scroll off. For long-running phases, poll the log file directly instead:

grep "SETUP_PAGE:" /tmp/rosN.log | tail -1

Keys Go to QEMU Monitor, Not Pilotty

# RIGHT — send to QEMU VGA console via monitor socket:
echo "sendkey ret" | socat - UNIX-CONNECT:/tmp/rosN.sock

# WRONG — sends to the tail process PTY:
npx pilotty key -s serial Enter

Serial Markers Reference

Marker Meaning
SETUP_PAGE:<name> Page transition (WELCOME, FILE_COPY, SUCCESS, ...)
SETUP_SELECTED:<text> Currently highlighted list item
COPY_FILE:<name> File being copied
PROGRESS:<N>% Install progress (0→100%)
kdb:> Kernel debugger prompt (crash)

Filesystem Selection Order

# Entry Keys from default
1 FAT quick format (default)
2 FAT full format Down
3 NTFS quick format Down, Down
4 NTFS full format Down, Down, Down
5 BTRFS quick format Down ×4
6 BTRFS full format Down ×5

ReactOS AMD64 Kernel Memory Layout

Virtual address space map for ReactOS on x86-64 (long mode, 4-level paging).

Sources: ntoskrnl/include/internal/amd64/mm.h, sdk/include/ndk/amd64/mmtypes.h, sdk/include/ndk/amd64/ketypes.h, sdk/include/xdk/amd64/mm.h, ntoskrnl/mm/amd64/init.c, ntoskrnl/mm/mminit.c

Verified against actual boot log output from MiInitSystemMemoryAreas().


Address Space Overview

0x0000000000000000 ┌─────────────────────────────────┐
                   │         User Space               │
                   │     (128 TB available)            │
0x000007FFFFFFFFFF └─────────────────────────────────┘
0x0000080000000000 ┌─────────────────────────────────┐
                   │   Non-canonical hole (#GP)       │
                   │   (inaccessible, ~16 million TB) │
0xFFFF7FFFFFFFFFFF └─────────────────────────────────┘
0xFFFF800000000000 ┌─────────────────────────────────┐
                   │       Kernel Space               │
                   │     (~128 TB available)           │
0xFFFFFFFFFFFFFFFF └─────────────────────────────────┘

User Space (0x00000000000000000x000007FFFFFFFFFF)

Address Macro Purpose
0x0000000000010000 MI_LOWEST_USER_ADDRESS Lowest usable user address (first 64 KB reserved / null guard)
0x000000007FFE0000 KI_USER_SHARED_DATA KUSER_SHARED_DATA page — system time, CPU features, tick count (readable from user mode)
0x000007FFFFFF0000 MI_USER_PROBE_ADDRESS Highest address for user-mode probing
0x000007FFFFFEFFFF MI_HIGHEST_USER_ADDRESS Absolute top of user-mode VA space

Kernel Space (0xFFFF8000000000000xFFFFFFFFFFFFFFFF)

Full Map (low → high)

0xFFFF080000000000  MI_DEFAULT_SYSTEM_RANGE_START (below canonical kernel)
0xFFFF800000000000  MI_REAL_SYSTEM_RANGE_START ─── kernel VA begins
     │
     │  (reserved — unused gap)
     │
0xFFFFF68000000000  PTE_BASE ──────────────────── 512 GB page table self-map
0xFFFFF6FB40000000  PDE_BASE ──────────────────── 1 GB  page directory self-map
0xFFFFF6FB7DA00000  PPE_BASE ──────────────────── 4 MB  page dir pointer self-map
0xFFFFF6FB7DBED000  PXE_BASE ──────────────────── 4 KB  PML4 self-map
0xFFFFF6FFFFFFFFFF  PTE_TOP
     │
0xFFFFF70000000000  HYPER_SPACE ───────────────── 512 GB hyperspace
0xFFFFF77FFFFFFFFF  HYPER_SPACE_END
     │
0xFFFFF78000001000  MI_SYSTEM_CACHE_WS_START ──── system cache working set
     │
0xFFFFF80000000000  KSEG0_BASE ───────────────── boot-loaded images
     │  ntoskrnl.exe @ 0xFFFFF80000400000 (ImageBase=0x400000)
     │  hal.dll      @ 0xFFFFF80003000000
     │  kdcom.dll, bootvid.dll, ...
     │  (boot log: 0xFFFFF80000000000 - 0xFFFFF80004E00000)
     │
0xFFFFF88000000000  MM_SYSTEM_SPACE_START ──────── system PTEs + driver space
     │  Drivers loaded here (ntfs.sys, scsiport.sys, classpnp.sys, ...)
     │  Kernel stacks allocated here (0xFFFFF880745xxxxx)
     │  (boot log: 0xFFFFF88000000000 - 0xFFFFF880762A1000)
     │
0xFFFFF89FFFFFF000  MI_DEBUG_MAPPING ──────────── debugger mapping page
     │
0xFFFFF8A000000000  MI_PAGED_POOL_START ────────── paged pool
     │  (boot log: 0xFFFFF8A000000000 - 0xFFFFF8A002000000 initial)
0xFFFFF8BFFFFFFFFF  (MI_PAGED_POOL_END, implicit)
     │
0xFFFFF90000000000  (MI_SESSION_SPACE_START, commented out — see session layout below)
     │
     │  ┌── Session Space (608 MB, grows DOWN from MI_SESSION_SPACE_END) ──┐
     │  │                                                                   │
0xFFFFF97FBA000000  │  System View Space (512 MB)                           │
0xFFFFF97FDA000000  │  MmSessionBase / Session Pool Start (64 MB)           │
0xFFFFF97FDE000000  │  Session View Start (512 MB)                          │
0xFFFFF97FFE000000  │  Session Working Set (16 MB)                          │
0xFFFFF97FFF000000  │  Session Image Space (16 MB)                          │
0xFFFFF98000000000  │  MI_SESSION_SPACE_END                                 │
     │  └──────────────────────────────────────────────────────────────────┘
     │
0xFFFFF98000000000  MI_SYSTEM_CACHE_START ──────── 1 TB system cache / dynamic VA
0xFFFFFA7FFFFFFFFF  MI_SYSTEM_CACHE_END
     │
0xFFFFFA8000000000  MI_PFN_DATABASE ───────────── PFN database (one MMPFN per physical page)
     │  (boot log: 0xFFFFFA8000000000 - 0xFFFFFA8000901000)
     │
0xFFFFFA8000901000  Non Paged Pool ───────────── ARM3 non-paged pool (follows PFN DB)
     │  (boot log: 0xFFFFFA8000901000 - 0xFFFFFA8001919000)
     │
0xFFFFFA8001919000  Non Paged Pool Expansion ──── expansion PTE space
     │  (boot log: 0xFFFFFA8001919000 - 0xFFFFFA800D00D000)
     │
0xFFFFFFFFFFBFFFFF  MI_NONPAGED_POOL_END
     │
0xFFFFFFFFFFC00000  MM_HAL_VA_START ──────────── 4 MB HAL VA space
     │
0xFFFFFFFFFFFE0000  APIC_BASE ────────────────── local APIC registers
     │
0xFFFFFFFFFFFFFFFF  MM_HAL_VA_END / MI_HIGHEST_SYSTEM_ADDRESS

Summary Table

Start Address End Address Size Macro(s) Purpose
0xFFFF800000000000 MI_REAL_SYSTEM_RANGE_START Start of kernel VA space
0xFFFFF68000000000 0xFFFFF6FFFFFFFFFF 512 GB PTE_BASE / PTE_TOP Page Table self-map
0xFFFFF6FB40000000 0xFFFFF6FB7FFFFFFF 1 GB PDE_BASE / PDE_TOP Page Directory self-map
0xFFFFF6FB7DA00000 0xFFFFF6FB7DBFFFFF 4 MB PPE_BASE / PPE_TOP Page Dir Pointer self-map
0xFFFFF6FB7DBED000 0xFFFFF6FB7DBEDFFF 4 KB PXE_BASE / PXE_TOP PML4 self-map
0xFFFFF6FB7DBEDF68 8 B PXE_SELFMAP Self-referencing PML4 entry
0xFFFFF70000000000 0xFFFFF77FFFFFFFFF 512 GB HYPER_SPACE Per-process temp kernel mappings
0xFFFFF78000001000 MI_SYSTEM_CACHE_WS_START System Cache Working Set
0xFFFFF80000000000 ~0xFFFFF80004E00000 ~78 MB KSEG0_BASE Boot-loaded images (ntoskrnl, HAL, boot drivers)
0xFFFFF88000000000 ~0xFFFFF880762A1000 ~1.8 GB MM_SYSTEM_SPACE_START System PTEs, drivers, kernel stacks
0xFFFFF89FFFFFF000 4 KB MI_DEBUG_MAPPING Debugger mapping page
0xFFFFF8A000000000 0xFFFFF8BFFFFFFFFF 128 GB MI_PAGED_POOL_START Paged Pool
0xFFFFF97FBA000000 0xFFFFF97FDA000000 512 MB (computed) System View Space
0xFFFFF97FDA000000 0xFFFFF98000000000 608 MB MmSessionBase Session Space (pool+view+ws+image)
0xFFFFF98000000000 0xFFFFFA7FFFFFFFFF 1 TB MI_SYSTEM_CACHE_START System Cache / Dynamic VA
0xFFFFFA8000000000 dynamic MI_PFN_DATABASE PFN Database
0xFFFFFA80_________ 0xFFFFFFFFFFBFFFFF dynamic Non-Paged Pool + Expansion
0xFFFFFFFFFFC00000 0xFFFFFFFFFFFFFFFF 4 MB MM_HAL_VA_START HAL VA space (includes APIC)

Session Space Detail (608 MB, grows downward from MI_SESSION_SPACE_END)

Computed in MiInitializeSessionSpaceLayout() (ntoskrnl/mm/amd64/init.c):

Start Address End Address Size Purpose
0xFFFFF97FDA000000 0xFFFFF97FDE000000 64 MB Session Pool (MiSessionPoolStart)
0xFFFFF97FDE000000 0xFFFFF97FFE000000 512 MB Session View (MiSessionViewStart)
0xFFFFF97FFE000000 0xFFFFF97FFF000000 16 MB Session Working Set (MiSessionSpaceWs)
0xFFFFF97FFF000000 0xFFFFF98000000000 16 MB Session Image (MiSessionImageStart)

4-Level Page Table Hierarchy

Virtual Address (48-bit canonical):

 63    48 47    39 38    30 29    21 20    12 11     0
┌────────┬────────┬────────┬────────┬────────┬────────┐
│  sign  │  PXE   │  PPE   │  PDE   │  PTE   │ offset │
│ extend │ (PML4) │ (PDPT) │  (PD)  │  (PT)  │        │
└────────┴────────┴────────┴────────┴────────┴────────┘
           9 bits   9 bits   9 bits   9 bits   12 bits
          512 ent  512 ent  512 ent  512 ent   4 KB page
Level Shift Entries Granularity Base Address
PXE (PML4) PXI_SHIFT = 39 512 512 GB 0xFFFFF6FB7DBED000
PPE (PDPT) PPI_SHIFT = 30 512 1 GB 0xFFFFF6FB7DA00000
PDE (PD) PDI_SHIFT = 21 512 2 MB 0xFFFFF6FB40000000
PTE (PT) PTI_SHIFT = 12 512 4 KB 0xFFFFF68000000000

Large pages: 2 MB (PDE.LargePage=1) and 1 GB (PPE.LargePage=1) are supported.


Hardware PTE Structure

typedef struct _HARDWARE_PTE {       // 64 bits
    ULONG64 Valid          : 1;      // [0]     Present
    ULONG64 Write          : 1;      // [1]     Read/Write
    ULONG64 Owner          : 1;      // [2]     User(1) / Supervisor(0)
    ULONG64 WriteThrough   : 1;      // [3]     Cache write-through
    ULONG64 CacheDisable   : 1;      // [4]     Cache disabled
    ULONG64 Accessed       : 1;      // [5]     Page accessed
    ULONG64 Dirty          : 1;      // [6]     Page modified
    ULONG64 LargePage      : 1;      // [7]     2 MB / 1 GB page
    ULONG64 Global         : 1;      // [8]     Global (no TLB flush on CR3 switch)
    ULONG64 CopyOnWrite    : 1;      // [9]     Software: Copy-on-Write
    ULONG64 Prototype      : 1;      // [10]    Software: Prototype PTE
    ULONG64 reserved0      : 1;      // [11]
    ULONG64 PageFrameNumber: 28;     // [12-39] Physical page frame number
    ULONG64 reserved1      : 12;     // [40-51]
    ULONG64 SoftwareWsIndex: 11;     // [52-62] Software: Working set index
    ULONG64 NoExecute      : 1;      // [63]    NX — No Execute
};

Memory Pool Sizes

Constant Value Purpose
MI_MIN_INIT_PAGED_POOLSIZE 32 MB Minimum initial paged pool
MI_MAX_INIT_NONPAGED_POOL_SIZE 128 GB Max initial nonpaged pool
MI_MAX_NONPAGED_POOL_SIZE 128 GB Max total nonpaged pool
MI_NUMBER_SYSTEM_PTES 484,000 System PTE count (22000 x 22)
MI_HYPERSPACE_PTES 255 Per-process hyperspace PTE slots
MI_MAX_ZERO_BITS 53 User VA allocation zero-bit limit
MI_SYSTEM_VIEW_SIZE 512 MB System view space

SYSCALL/MSR Configuration

MSR Address Purpose
MSR_EFER 0xC0000080 Extended Feature Enable (LME, NXE, SCE)
MSR_STAR 0xC0000081 SYSCALL CS/SS selectors
MSR_LSTAR 0xC0000082 SYSCALL entry point (64-bit)
MSR_CSTAR 0xC0000083 SYSCALL entry point (compat mode)
MSR_SYSCALL_MASK 0xC0000084 EFLAGS mask on SYSCALL
MSR_FS_BASE 0xC0000100 FS segment base (user TEB)
MSR_GS_BASE 0xC0000101 GS segment base (kernel KPCR)
MSR_GS_SWAP 0xC0000102 SWAPGS kernel/user GS base

Boot-Time Memory Initialization Order

From MiInitSystemMemoryAreas() in ntoskrnl/mm/mminit.c, with actual addresses from a 512 MB QEMU boot:

# Region Actual Range (boot log)
1 Boot Loaded Image 0xFFFFF80000000000 - 0xFFFFF80004E00000
2 PFN Database 0xFFFFFA8000000000 - 0xFFFFFA8000901000
3 ARM3 Non Paged Pool 0xFFFFFA8000901000 - 0xFFFFFA8001919000
4 Session Space 0xFFFFF97FDA000000 - 0xFFFFF98000000000
5 System Cache 0xFFFFF98000000000 - 0xFFFFFA7FFFFFFFFF
6 ARM3 Paged Pool 0xFFFFF8A000000000 - 0xFFFFF8A002000000
7 System PTE Space 0xFFFFF88000000000 - 0xFFFFF880762A1000
8 Non Paged Pool Expansion 0xFFFFFA8001919000 - 0xFFFFFA800D00D000

NX (No-Execute) Support

_MI_HAS_NO_EXECUTE = 1 — AMD64 always supports NX via bit 63 of the PTE. Enabled by setting NXE in MSR_EFER.

#!/usr/bin/env python3
"""
ReactOS NTFS install / boot / reinstall test harness.
Replaces utils.sh with a Python library that drives QEMU via the unix monitor
socket and observes the system through the serial-port log. Designed to be
imported and driven step-by-step from `python3 -c '...'` so you can adapt the
test to whatever the system is actually doing instead of running a fragile
"happy path" end-to-end script.
------------------------------------------------------------------
Workflow recipes
------------------------------------------------------------------
# Recipe 1: fresh install on a brand-new disk
from ros_test import RosQemu, install_ntfs, monitor_copy, finish_install
q = RosQemu("rostest", "build/bootcd.iso")
q.start(boot="d")
install_ntfs(q)
monitor_copy(q)
finish_install(q) # advances SUCCESS->FLUSH->REBOOT and waits for clean exit
# Recipe 2: reinstall over an existing install
q = RosQemu("rostest", "build/bootcd.iso", disk="/tmp/rostest.img")
q.start(boot="d")
install_ntfs(q)
monitor_copy(q)
finish_install(q)
# WARNING: do NOT q.kill() before finish_install returns. SUCCESS_PAGE waits
# 15s/ENTER before advancing to FLUSH_PAGE -> REBOOT_PAGE -> NtShutdownSystem.
# NTFS's IRP_MJ_SHUTDOWN handler (NtfsFlushVolume) is the only thing that
# pushes the BOOTLOADER_INSTALL writes (freeldr.ini, BOOTSECT.OLD) out of Cc.
# Killing earlier leaves those non-resident files with allocated clusters but
# zero contents on disk — the partition then drops freeldr into its
# "Setup and Configuration" menu instead of booting ReactOS.
# Recipe 3: full end-to-end (install + HD boot verification)
python3 ros_test.py build/bootcd.iso
------------------------------------------------------------------
State on disk
------------------------------------------------------------------
For each instance `name`, RosQemu owns three files:
/tmp/{name}.img # the disk (raw or qcow2 by extension)
/tmp/{name}.log # QEMU's serial output (the only event source)
/tmp/{name}.sock # QEMU's monitor unix socket (sendkey, screendump, ...)
Display is on VNC :84 (port 5984). No `-nic` so the guest can't escape.
"""
import socket, time, subprocess, os, sys, re, signal, struct
def _qemu_pids_for_disk(disk):
disk_name = os.path.basename(disk)
try:
out = subprocess.check_output(["ps", "-eo", "pid=,comm=,args="],
text=True, stderr=subprocess.DEVNULL)
except subprocess.CalledProcessError:
return []
pids = []
for line in out.splitlines():
parts = line.strip().split(None, 2)
if len(parts) < 3:
continue
pid, comm, args = parts
if comm.startswith("qemu-system-") and disk_name in args:
pids.append(int(pid))
return pids
class RosQemu:
def __init__(self, name="rostest", iso="build/bootcd.iso", disk=None):
self.name = name
self.sock_path = f"/tmp/{name}.sock"
self.log_path = f"/tmp/{name}.log"
self.iso = iso
self.pid = None
if disk:
self.disk = disk
else:
self.disk = f"/tmp/{name}.img"
if os.path.exists(self.disk):
os.unlink(self.disk)
subprocess.run(["truncate", "-s", "10G", self.disk], check=True)
self.fmt = "qcow2" if self.disk.endswith(".qcow2") else "raw"
def start(self, boot="d"):
"""Launch QEMU. boot="d" boots from CD, boot="c" from HD."""
# Kill old instance
try:
for pid in _qemu_pids_for_disk(self.disk):
os.kill(int(pid), 9)
time.sleep(1)
except ProcessLookupError:
pass
for f in [self.log_path, self.sock_path]:
if os.path.exists(f):
os.unlink(f)
cmd = [
"qemu-system-x86_64", "-m", "2048",
"-drive", f"file={self.disk},format={self.fmt},if=ide",
"-boot", boot,
"-serial", f"file:{self.log_path}",
"-monitor", f"unix:{self.sock_path},server,nowait",
"-display", "gtk",
"-no-reboot", "-nic", "none", "-enable-kvm", "-daemonize",
]
if boot == "d":
cmd += ["-cdrom", self.iso]
subprocess.run(cmd, check=True, capture_output=True)
time.sleep(0.5)
pids = _qemu_pids_for_disk(self.disk)
self.pid = pids[0] if pids else None
print(f"QEMU started: PID={self.pid} disk={self.disk} boot={boot}")
return self
def start_luagent(self, usb_image, host_port=7000, mem=1024,
gdb=True, com2_sock=None):
"""Boot the bootcd ISO with a USB-MSC drive (so partmgr exposes it as C:),
a hostfwd'd rtl8139 NIC for luagent (port 7000 in guest -> host_port on
host), COM1 streamed to {name}.log, COM2 on a unix socket for the
serial-shell (used by hello.exe), and -s for a gdb stub.
"""
# Kill any prior instance of this name
try:
out = subprocess.check_output(
["pgrep", "-f", f"qemu.*-name {self.name}\\b"],
text=True, stderr=subprocess.DEVNULL)
for pid in out.strip().split():
os.kill(int(pid), 9)
time.sleep(0.5)
except (subprocess.CalledProcessError, ProcessLookupError):
pass
if com2_sock is None:
com2_sock = f"/tmp/{self.name}-com2.sock"
for f in [self.log_path, self.sock_path, com2_sock]:
if os.path.exists(f):
os.unlink(f)
self.com2_sock = com2_sock
self.host_port = host_port
cmd = [
"qemu-system-x86_64", "-m", str(mem),
"-cdrom", self.iso, "-boot", "d",
"-device", "qemu-xhci,id=xhci",
"-drive", f"if=none,id=usbdrv,file={usb_image},format=raw",
"-device", "usb-storage,bus=xhci.0,drive=usbdrv",
"-netdev", f"user,id=net0,hostfwd=tcp:127.0.0.1:{host_port}-:7000",
"-device", "rtl8139,netdev=net0",
"-chardev", f"file,id=com1,path={self.log_path}",
"-chardev", f"socket,id=com2,path={com2_sock},server=on,wait=off",
"-serial", "chardev:com1",
"-serial", "chardev:com2",
"-monitor", f"unix:{self.sock_path},server,nowait",
"-rtc", "base=2026-05-07T12:00:00,clock=host",
"-display", "gtk",
"-no-reboot", "-enable-kvm",
"-name", self.name, "-daemonize",
]
if gdb:
cmd += ["-s"]
subprocess.run(cmd, check=True, capture_output=True)
time.sleep(0.5)
try:
out = subprocess.check_output(
["pgrep", "-f", f"qemu.*-name {self.name}\\b"],
text=True, stderr=subprocess.DEVNULL)
self.pid = int(out.strip().split()[0])
except Exception:
self.pid = None
print(f"QEMU started (luagent rig): PID={self.pid} usb={usb_image} "
f"host_port={host_port}", flush=True)
return self
def wait_luagent_listen(self, timeout=180):
"""Block until the in-guest luagent has bound and is accepting on
port 7000. We match the diag_log line emitted *after* uv_listen
succeeds (not the early `server_run` trace, which races vs. the bind)."""
return self.wait("net listening host=0.0.0.0 port=7000",
timeout=timeout)
def luagent(self, host="127.0.0.1", port=None, hello_timeout=10):
"""Open a LuagentClient against this guest's hostfwd port."""
if port is None:
port = getattr(self, "host_port", 7000)
return LuagentClient(self, host=host, port=port, hello_timeout=hello_timeout)
def kill(self):
if hasattr(self, '_mon') and self._mon:
try:
self._mon.close()
except Exception:
pass
self._mon = None
if self.pid:
try:
os.kill(self.pid, 9)
except ProcessLookupError:
pass
self.pid = None
def restart(self, boot="d"):
self.kill()
time.sleep(1)
return self.start(boot=boot)
def _ensure_monitor(self):
if hasattr(self, '_mon') and self._mon:
return
self._mon = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self._mon.settimeout(2)
self._mon.connect(self.sock_path)
try:
self._mon.recv(4096)
except socket.timeout:
pass
self._mon.settimeout(1)
def send(self, key):
self.monitor_cmd(f"sendkey {key}")
def monitor_cmd(self, cmd):
try:
self._ensure_monitor()
self._mon.sendall((cmd + "\n").encode())
time.sleep(0.05)
try:
self._mon.recv(4096)
except socket.timeout:
pass
except Exception as e:
print(f" monitor_cmd({cmd!r}) failed: {e}", file=sys.stderr)
self._mon = None
def is_alive(self):
if self.pid:
try:
os.kill(self.pid, 0)
return True
except (ProcessLookupError, PermissionError):
return False
return bool(_qemu_pids_for_disk(self.disk))
def screenshot(self, name="shot"):
path = f"/tmp/{self.name}-{name}.ppm"
if os.path.exists(path):
os.unlink(path)
self.monitor_cmd(f"screendump {path}")
deadline = time.time() + 2
last = -1
while time.time() < deadline:
if os.path.exists(path):
sz = os.path.getsize(path)
if sz > 0 and sz == last:
break
last = sz
time.sleep(0.05)
try:
return path, open(path, "rb").read()
except FileNotFoundError:
return path, b""
def log_lines(self):
try:
with open(self.log_path, "r", errors="replace") as f:
return f.readlines()
except FileNotFoundError:
return []
def log_count(self, pattern):
return sum(1 for l in self.log_lines() if pattern in l)
def spam_keys_until(self, key, pattern, timeout=30, interval=0.4):
deadline = time.time() + timeout
sent = 0
while time.time() < deadline:
if any(pattern in l for l in self.log_lines()):
return True, sent
self.send(key)
sent += 1
time.sleep(interval)
return any(pattern in l for l in self.log_lines()), sent
# ── Core poll loop ──
def _poll(self, check, timeout, desc, interval=0.3, allow_crashed=False):
"""Core poll loop: call check() until truthy, bail on QEMU exit, ROS
crash (kdb/Assertion/BugCheck), or timeout. Set allow_crashed=True if
the caller is intentionally polling FOR a crash."""
deadline = time.time() + timeout
while time.time() < deadline:
result = check()
if result:
return result
if not self.is_alive():
raise RuntimeError(f"QEMU exited while waiting for: {desc}")
if not allow_crashed:
kind = self.crashed()
if kind:
raise RuntimeError(
f"ROS {kind} while waiting for: {desc} — last lines:\n"
+ "".join(self.log_lines()[-10:]))
time.sleep(interval)
raise TimeoutError(f"timeout({timeout}s) waiting for: {desc}")
def wait(self, pattern, timeout=10):
def check():
return any(pattern in l for l in self.log_lines())
return self._poll(check, timeout, pattern)
def wait_new(self, pattern, timeout=10):
before = self.log_count(pattern)
def check():
return self.log_count(pattern) > before
return self._poll(check, timeout, f"new: {pattern}")
def wait_any(self, patterns, timeout=10):
def check():
lines = self.log_lines()
for p in patterns:
if any(p in l for l in lines):
return p
return self._poll(check, timeout, f"any of: {patterns}", interval=0.2)
def wait_any_new(self, patterns, timeout=10):
before = {p: self.log_count(p) for p in patterns}
def check():
for p in patterns:
if self.log_count(p) > before[p]:
return p
return self._poll(check, timeout, f"new of any: {patterns}", interval=0.2)
def do(self, key, pattern, timeout=10, label=""):
if label:
print(f" {label}", file=sys.stderr)
before = self.log_count(pattern)
self.send(key)
def check():
return self.log_count(pattern) > before
return self._poll(check, timeout, f"new: {pattern} (after '{key}')")
# Markers that indicate the guest stopped making forward progress.
# Order matters: more specific first.
_CRASH_MARKERS = (
("assertion", "*** Assertion failed"),
("bugcheck", "*** STOP:"),
("bugcheck", "*** Fatal System Error:"),
("bugcheck", "BugCheck"),
("kdb", "kdb:>"),
("luagent_crash", "luagent: unhandled exception"),
)
def crashed(self):
"""Return a short tag (str) describing the crash, or None."""
lines = self.log_lines()
for tag, marker in self._CRASH_MARKERS:
if any(marker in l for l in lines):
return tag
return None
def crash_context(self, n=15):
"""Last n log lines, joined; useful in error messages."""
return "".join(self.log_lines()[-n:])
def last_selected(self):
for l in reversed(self.log_lines()):
if "SETUP_SELECTED:" in l:
return l.strip()
return ""
# ──────────────────────────────────────────────────────────────────────────
# Luagent protocol client (paired with RosQemu.start_luagent / .luagent())
# ──────────────────────────────────────────────────────────────────────────
class LuagentClient:
"""Thin client for reactos-luagent's framed protocol.
Bound to a RosQemu instance so every blocking read also polls
`q.crashed()`/`q.is_alive()` — no wait will sit through a kdb prompt.
Frame layout: 'RXSH' magic (4) | type (1) | flags (1) | req_id u32 | len u32 | payload
"""
MAGIC = b"RXSH"
HELLO = 1; ACK = 2; ERROR = 3
OP = 10; OPRES = 11
SPAWN = 40; STDOUT = 42; STDERR = 43; EXIT = 44; KILL = 45
PING = 50; PONG = 51
def __init__(self, q, host="127.0.0.1", port=7000, hello_timeout=10):
self.q = q
self.s = socket.create_connection((host, port), timeout=hello_timeout)
self.s.settimeout(0.5) # short slice; outer loops re-check q.crashed()
self._send(self.HELLO, 1, b"client=ros_test\nversion=1")
typ, _, _, _ = self._recv_frame(timeout=hello_timeout)
if typ != self.ACK:
raise RuntimeError(f"HELLO did not get ACK (got type={typ})")
@classmethod
def _encode(cls, t, req, payload=b""):
return cls.MAGIC + bytes([t, 0]) + struct.pack("<II", req, len(payload)) + payload
def _send(self, t, req, payload=b""):
self.s.sendall(self._encode(t, req, payload))
def _recv_exact(self, n, deadline):
out = b""
while len(out) < n:
try:
c = self.s.recv(n - len(out))
except socket.timeout:
if time.time() > deadline:
raise TimeoutError(f"want {n}, got {len(out)}")
k = self.q.crashed()
if k:
raise RuntimeError(
f"ROS {k} during recv — last lines:\n{self.q.crash_context()}")
if not self.q.is_alive():
raise RuntimeError("QEMU exited during recv")
continue
if not c:
raise EOFError(f"EOF after {len(out)}/{n}")
out += c
return out
def _recv_frame(self, timeout=30):
deadline = time.time() + timeout
h = self._recv_exact(14, deadline)
if h[:4] != self.MAGIC:
raise RuntimeError(f"bad magic {h[:4]!r}")
typ = h[4]
req, plen = struct.unpack("<II", h[6:14])
payload = self._recv_exact(plen, deadline) if plen else b""
return typ, req, plen, payload
def spawn(self, path, *argv, timeout_ms=1800000, idle_timeout_ms=600000, req=1000):
"""Send a SPAWN frame. Note: `argv` should be the FULL argv (argv0,
argv1, ...) matching pacman's expectations; if you want pacman to see
argv[0] as its own path, pass `path` again as the first item."""
if not argv:
argv = (path,)
kv = (f"path={path}\n"
+ "\n".join(f"argv{i}={a}" for i, a in enumerate(argv))
+ f"\ntimeout_ms={timeout_ms}\nidle_timeout_ms={idle_timeout_ms}\n")
self._send(self.SPAWN, req, kv.encode())
def stream(self, *, on_stdout=None, on_stderr=None, deadline=None,
echo=True):
"""Read frames forever (handling PING with auto-PONG) until either:
• EXIT frame arrives → returns ("exit", parsed-kv-dict)
• absolute `deadline` (time.time() epoch) elapses → ("timeout", None)
• EOF / connection reset → ("eof", None)
• ROS crashes / QEMU dies → raises RuntimeError via _recv_*
If `echo` is true (default), STDOUT/STDERR payloads are mirrored to
sys.stdout with OUT|/ERR| prefixes. `on_stdout`/`on_stderr` get the
raw bytes for additional handling.
"""
proc_id = None
while True:
now = time.time()
slice_to = (deadline - now) if deadline else 5.0
if slice_to <= 0:
return "timeout", None
# Check the guest log every iteration so a crash that happens
# *between* frames is surfaced immediately and not as a vague EOF.
kind = self.q.crashed()
if kind:
raise RuntimeError(
f"ROS {kind} during stream — last lines:\n{self.q.crash_context()}")
try:
typ, req, plen, payload = self._recv_frame(timeout=min(slice_to, 5.0))
except TimeoutError:
continue
except (EOFError, ConnectionError):
# If the guest crashed, prefer the precise diagnosis.
kind = self.q.crashed()
if kind:
raise RuntimeError(
f"ROS {kind} during stream — last lines:\n{self.q.crash_context()}")
return "eof", None
if typ == self.PING:
try:
self._send(self.PONG, req, b"status=ok")
except Exception:
pass
continue
if typ == self.STDOUT:
if echo:
sys.stdout.write("OUT|" + payload.decode(errors="replace"))
sys.stdout.flush()
if on_stdout:
on_stdout(payload)
continue
if typ == self.STDERR:
if echo:
sys.stdout.write("ERR|" + payload.decode(errors="replace"))
sys.stdout.flush()
if on_stderr:
on_stderr(payload)
continue
if typ == self.ACK:
kv = self._kv(payload)
proc_id = kv.get("proc_id", proc_id)
if echo:
sys.stdout.write(f"ACK|{kv}\n"); sys.stdout.flush()
continue
if typ == self.ERROR:
kv = self._kv(payload)
if echo:
sys.stdout.write(f"ERR-FRAME|{kv}\n"); sys.stdout.flush()
continue
if typ == self.EXIT:
kv = self._kv(payload)
if echo:
sys.stdout.write(f"EXIT|{kv}\n"); sys.stdout.flush()
return "exit", kv
if echo:
sys.stdout.write(f"UNK|type={typ} req={req} len={plen}\n")
sys.stdout.flush()
@staticmethod
def _kv(payload):
try:
text = payload.decode(errors="replace")
except Exception:
return {"_raw": repr(payload)}
out = {}
for line in text.splitlines():
if "=" in line:
k, v = line.split("=", 1)
out[k] = v
return out
def close(self):
try: self.s.close()
except Exception: pass
def install_ntfs(q):
"""Drive usetup from boot prompt to FILE_COPY."""
print("=== NTFS Install ===")
# The NT10/amd64 bootcd used here does not emit the older SETUP_PAGE
# breadcrumbs. Drive the visible text setup path and anchor only on the
# serial lines this build still emits.
print(" boot menu -> setup debug")
q.send("home")
time.sleep(0.2)
for _ in range(3):
q.send("down")
time.sleep(0.1)
q.send("ret")
q.wait("SourcePath (1):", timeout=90)
time.sleep(2)
print(" setup intro -> partition list")
for _ in range(3):
q.send("ret")
time.sleep(0.8)
print(" create/use primary partition -> filesystem list")
q.send("ret")
q.wait("SETUP_SELECTED:", timeout=30)
for i in range(12):
q.do("down", "SETUP_SELECTED:", 5, f"filesystem option {i + 1}")
sel = q.last_selected()
if "NTFS" in sel:
break
else:
raise RuntimeError(f"Expected NTFS, got: {q.last_selected()}")
print(f" Confirmed: {sel}")
print(" accepting NTFS quick format")
q.send("ret")
time.sleep(1)
q.send("ret")
q.wait_any([
"NtOpenFile failed: c0000034",
"Copy hive:",
"Install NTFS bootcode:",
], timeout=120)
print("=== FILE_COPY started ===")
def monitor_copy(q, timeout_per_pct=180):
"""Watch FILE_COPY, bail on crash/OOM/stall."""
print("=== Monitoring file copy ===")
last_pct = ""
last_time = time.time()
start = time.time()
while True:
if not q.is_alive():
print(f"QEMU exited after {time.time()-start:.0f}s")
return False
if q.crashed():
print(f"CRASH after {time.time()-start:.0f}s!")
for l in q.log_lines():
if "Assertion" in l or "kdb:>" in l:
print(f" {l.strip()}")
return False
lines = q.log_lines()
pcts = [l for l in lines if "PROGRESS:" in l]
if pcts:
m = re.search(r'(\d+)%', pcts[-1])
if m and m.group(0) != last_pct:
last_pct = m.group(0)
last_time = time.time()
print(f" Progress: {last_pct} ({time.time()-start:.0f}s)")
if any("SETUP_PAGE:REGISTRY" in l or "SETUP_PAGE:BOOT_LOADER_INSTALL" in l
or "SETUP_PAGE:SUCCESS" in l or "Install NTFS bootcode:" in l
or "NtfsShutdown()" in l for l in lines):
print(f"FILE_COPY complete ({time.time()-start:.0f}s)")
return True
if time.time() - last_time > timeout_per_pct:
print(f"STALL: no progress for {timeout_per_pct}s (last: {last_pct})")
return False
time.sleep(1)
def finish_install(q, success_timeout=120, reboot_timeout=120):
"""Walk usetup from monitor_copy's exit point to a clean guest reboot.
Why this exists: SuccessPage(usetup.c) waits 15s or for ENTER, then
falls through FlushPage -> REBOOT_PAGE -> NtShutdownSystem(ShutdownReboot).
The IRP_MJ_SHUTDOWN that fires next is the ONLY pass that runs
NtfsFlushVolume / CcFlushCache on the BOOTLOADER_INSTALL writes
(freeldr.ini, BOOTSECT.OLD, etc) so they actually reach disk. If the
caller q.kill()s before NtShutdownSystem runs, those non-resident
files end up with allocated clusters but zero contents — freeldr
then has nothing to load and drops into its setup-and-configuration
menu on next boot.
QEMU is launched with -no-reboot, so it terminates when the guest
requests a reboot. We just wait for that exit; that's the signal
that the shutdown handler ran to completion.
"""
print("=== Driving usetup to clean reboot ===")
# 1) Advance through any post-copy pages until either the old explicit
# SETUP_PAGE:SUCCESS marker appears, or the current build reaches the
# NTFS bootcode/shutdown path.
deadline = time.time() + success_timeout
while time.time() < deadline:
lines = q.log_lines()
if any("SETUP_PAGE:SUCCESS" in l or "Install NTFS bootcode:" in l
or "NtfsShutdown()" in l for l in lines[-300:]):
print(" setup success/shutdown path reached")
break
if not q.is_alive():
print(" QEMU exited during setup finalization")
return True
q.send("ret")
time.sleep(2)
else:
raise TimeoutError(
f"never saw SETUP_PAGE:SUCCESS in {success_timeout}s")
# 2) Send the final ENTER. SuccessPage returns FLUSH_PAGE; FlushPage
# returns REBOOT_PAGE; the main loop falls out; FinishSetup runs;
# NtShutdownSystem(ShutdownReboot) fires; IRP_MJ_SHUTDOWN walks the
# NTFS VCB list and flushes every dirty FCB; guest reboots; QEMU
# -no-reboot makes the process exit.
print(" Waiting for FLUSH->REBOOT path")
# 3) Wait for QEMU to exit on the guest's reboot request.
deadline = time.time() + reboot_timeout
while time.time() < deadline:
if not q.is_alive():
print(" QEMU exited cleanly — NtShutdownSystem flush completed")
return True
q.send("ret")
time.sleep(1)
raise TimeoutError(
f"QEMU did not exit within {reboot_timeout}s after SUCCESS; "
f"the shutdown path probably hung. Do NOT kill blindly — first "
f"investigate, killing now will lose dirty Cc data.")
def boot_from_hd(q):
"""Kill install QEMU, relaunch with boot=c, wait for session init."""
print("=== Booting from HD ===")
q.kill()
time.sleep(1)
for f in [q.log_path, q.sock_path]:
if os.path.exists(f):
os.unlink(f)
cmd = [
"qemu-system-x86_64", "-m", "512",
"-drive", f"file={q.disk},format={q.fmt},if=ide",
"-boot", "c",
"-serial", f"file:{q.log_path}",
"-monitor", f"unix:{q.sock_path},server,nowait",
"-display", "gtk",
"-no-reboot", "-nic", "none", "-enable-kvm", "-daemonize",
]
subprocess.run(cmd, check=True, capture_output=True)
time.sleep(0.5)
try:
pids = _qemu_pids_for_disk(q.disk)
q.pid = pids[0] if pids else None
except Exception:
q.pid = None
print(f" Booting from HD (PID={q.pid})")
deadline = time.time() + 120
while time.time() < deadline:
if q.crashed():
print("FAIL: BSOD/assertion on HD boot!")
for l in q.log_lines():
if "Assertion" in l or "kdb:>" in l:
print(f" {l.strip()}")
return False
if not q.is_alive():
print("FAIL: QEMU exited unexpectedly")
return False
if any("Session 0 is ready" in l for l in q.log_lines()):
print("PASS: Booted to session init!")
return True
time.sleep(1)
print("TIMEOUT: no crash, no session after 120s")
for l in q.log_lines()[-10:]:
print(f" {l.strip()}")
return False
def drive_first_boot_wizard(q, max_pages=12, page_timeout=45):
"""Drive the GUI first-boot wizard.
Text-mode setup is observable through serial, but this GUI wizard is not.
Treat each ENTER as a single "Next" action, then wait for either a screen
transition, a guest reboot, or a crash. Some pages are async workers
("Installing devices", "Network Install"), so later steps intentionally
use longer timeouts and do not treat a temporarily unchanged framebuffer as
success.
"""
import hashlib
def screen_hash(name):
_, data = q.screenshot(name)
return hashlib.md5(data).hexdigest() if data else None
def wait_screen_change(old_hash, timeout, label):
start = time.time()
last_report = start
while time.time() - start < timeout:
if not q.is_alive():
print(f" {label}: guest rebooted/exited")
return "exit"
if q.crashed():
print(f" {label}: crash detected")
return "crash"
new_hash = screen_hash(label.replace(" ", "_"))
if new_hash and old_hash and new_hash != old_hash:
print(f" {label}: advanced after {time.time() - start:.1f}s")
return "changed"
if time.time() - last_report >= 30:
print(f" {label}: still waiting ({time.time() - start:.0f}s)")
last_report = time.time()
time.sleep(1)
print(f" {label}: no framebuffer change after {timeout}s")
return "timeout"
print("=== Driving first-boot wizard ===")
step_timeouts = [
page_timeout, page_timeout, page_timeout, page_timeout,
240, 240, 180, 120, 120, 120, 120, 120,
]
for i in range(max_pages):
if not q.is_alive():
print(f" guest exited before wizard step {i}")
return True
if q.crashed():
print(f" CRASH detected at wizard step {i}")
return False
cur_hash = screen_hash(f"wiz{i}")
if cur_hash is None:
print(f" step {i}: screenshot failed, retrying")
time.sleep(0.5)
continue
timeout = step_timeouts[i] if i < len(step_timeouts) else page_timeout
print(f" step {i}: {cur_hash[:8]} -> ENTER (timeout {timeout}s)")
q.send("ret")
result = wait_screen_change(cur_hash, timeout, f"wizard step {i}")
if result == "exit":
return True
if result == "crash":
return False
# Wait for reboot, but detect stalls: if the screen doesn't change
# for stall_timeout seconds, send Enter (an error dialog may be
# blocking) and resume waiting.
print("=== Waiting for guest reboot (or crash) ===")
stall_timeout = 90
last_change = time.time()
prev_hash = None
deadline = time.time() + 600
while time.time() < deadline:
if not q.is_alive():
elapsed = int(time.time() - (deadline - 600))
print(f" guest rebooted (qemu exited) at t={elapsed}s")
return True
if q.crashed():
print(" CRASH/kdb during finalize phase")
return False
_, data = q.screenshot("finalize")
h = hashlib.md5(data).hexdigest() if data else None
if h and h != prev_hash:
prev_hash = h
last_change = time.time()
elif time.time() - last_change > stall_timeout:
print(f" stall detected ({stall_timeout}s unchanged), sending Enter")
q.send("ret")
last_change = time.time()
time.sleep(2)
print(" timeout: wizard did not finalize within 600s")
return False

Driving ReactOS Setup End-to-End from Python (ros_test.py)

A complete recipe for booting QEMU, walking the text-mode setup, surviving the reboot, driving the GUI first-boot wizard, and verifying the install reached the desktop — entirely event-driven, no time.sleep() in the test logic. This is what catches NTFS regressions like the bitmap-TOCTOU double-allocation that produced an INDX assertion at the "Updating registry hives" stage.

Why event-driven matters here

ReactOS's install path has three regimes that each behave differently:

  1. El Torito CD prompt + FreeLDR menu — auto-advances after a few seconds. You can't wait() on these because they emit nothing to the serial port. You have to spam ENTER until the first kernel-side marker (SETUP_PAGE:LANGUAGE) appears, then stop immediately.
  2. Text-mode setup (usetup) — emits SETUP_PAGE: lines for every transition and SETUP_SELECTED: lines for list navigation. Every advancement is a deterministic wait_any_new().
  3. GUI first-boot wizard — a Win32 program that does not print page transitions to the serial port at all. The screen is the only source of truth. We hash the raw screendump PPM bytes and treat any change as "page advanced", which is the same definition the user has when watching VNC.

Mixing fixed sleeps into any of those is fragile: KVM vs TCG, cold vs warm cache, host load — the timing varies enough to break a sleep-based driver.

Library shape

ros_test.py exposes a RosQemu class with these primitives:

Primitive What it does
q.start(boot="d") launch QEMU, kill any prior instance on the same disk
q.restart(boot="c") kill + relaunch on the same disk (HD-only after install)
q.send("ret") one sendkey over the unix monitor socket
q.spam_keys_until(key, pat, timeout) press repeatedly until pat appears
q.do(key, pat, timeout, label) send + wait_any_new + assert + label
q.wait("PAT", timeout) block until next occurrence (one matcher)
q.wait_any_new([...], timeout) block until ANY of N matchers fires next
q.last_selected() last SETUP_SELECTED: line (for FS-list confirmation)
q.log_count("PAT") how many times PAT has appeared so far
q.screenshot(name) one screendump over the monitor (returns bytes)
q.is_alive() / q.crashed() process check / kdb-prompt check

The full setup-from-boot-to-FILE_COPY is install_ntfs(q). It internally forks on whether it sees SETUP_PAGE:UPGRADE_REPAIR (existing install, press ESC) or SETUP_PAGE:DEVICE_SETTINGS (fresh disk).

Stage 1 — install on a fresh disk

from ros_test import RosQemu, install_ntfs, monitor_copy

q = RosQemu(name="rostest", iso="build/bootcd.iso")  # creates /tmp/rostest.img
q.start(boot="d")                                     # CD then HD
install_ntfs(q)                                       # to FILE_COPY started
monitor_copy(q)                                       # follow per-percent
q.wait("SETUP_PAGE:SUCCESS", 90)                      # text-mode finished
q.send("ret")                                         # reboot into wizard

monitor_copy(q) prints each new progress percent and bails out on a detected crash, OOM, or stall (per-percent timeout, not wall clock — slow disks are fine, deadlocks are not).

Stage 2 — surviving the reboot, driving the wizard

ReactOS is launched with -no-reboot so a guest-initiated restart causes QEMU to exit. Use q.is_alive() to detect the reboot and q.restart() to relaunch on the same disk:

from ros_test import drive_first_boot_wizard

# Wait for the guest to reboot — qemu exits, no marker required
import time
while q.is_alive():
    time.sleep(1)            # busy-wait on the process; no log polling
q.restart(boot="c")          # boot from HD only, no CD this time

# The first thing usetup-as-installer does on the new boot is the
# wizard.  Drive it via screen-hash polling.
drive_first_boot_wizard(q)

drive_first_boot_wizard walks each page by:

  1. Hashing the framebuffer.
  2. Sending ENTER (which on every wizard page we've observed triggers the default "Next" button — Welcome, Acknowledgements, Product Options, Regional, Personalize, Computer Name, Date/Time, Theme, Network, Workgroup, Finishing).
  3. Polling the framebuffer hash with no fixed sleep — the moment it changes, we move on.
  4. Stopping when ENTER produces no change for page_timeout seconds. That means we're either on the auto-finalize "Finishing the Installation" page (which doesn't react to keys, only to its own internal progress) — or the system already booted past the wizard to the desktop, which is also a stable framebuffer.

It returns True if it observed the guest reboot during finalization, False otherwise. Pair it with q.is_alive() afterwards.

Stage 3 — verifying the second boot

This is the boot that originally failed with the INDX assertion. After the wizard reboots, relaunch QEMU one more time and watch for either the assertion or the desktop:

if not q.is_alive():
    q.restart(boot="c")

hit = q.wait_any_new(
    ["Assertion failed",
     "Setup failed to create",
     "explorer.exe",
     "userinit",
     "WelcomeScreen"],
    timeout=600,
)
print(f"outcome: {hit}")
print(f"alive={q.is_alive()} crashed={q.crashed()}")

If hit starts with Assertion or Setup failed, the bug is back. If it's explorer.exe / userinit / WelcomeScreen, the install reached user mode.

To visually confirm the desktop, take a final screenshot:

import subprocess
subprocess.run(
    ["sh", "-c",
     'echo "screendump /tmp/done.ppm" '
     '| nc -U -q1 /tmp/rostest.sock; '
     'python3 -c "from PIL import Image; '
     'Image.open(\'/tmp/done.ppm\').save(\'/tmp/done.png\')"'])

A successful install shows My Documents, My Computer, Recycle Bin, the Start menu, and the taskbar.

Asserting your driver fix actually fired

Each NTFS fix in this repo can be regression-tested by counting log events from the harness:

print("dismounts:           ", q.log_count("NtfsDismountVolume("))
print("mounts:              ", q.log_count("NtfsMountVolume() called"))
print("INDX assertions:     ", q.log_count("Assertion failed"))
print("Reg init failed:     ", q.log_count("RegInitializeRegistry() failed"))
print("LockOrUnlockVolume:  ", q.log_count("LockOrUnlockVolume:"))

If the fix is supposed to make a previously-fatal event happen 0 times, the count is the assertion. If the fix is supposed to make a previously absent code path run, the count being non-zero is the proof.

Common pitfalls (and the fix)

Pitfall: wait_any_new() never matches because the marker already streamed

wait_any_new() only matches new occurrences after it's called. If the wizard was already running before you called it (e.g., you attached to an existing QEMU instead of starting one), the markers may have already gone by.

Fix: for "is X happening right now?" use q.log_count("X") and check the delta yourself, OR skip the marker wait entirely and call drive_first_boot_wizard(q) — the screen-hash polling doesn't need a serial marker.

Pitfall: drive_first_boot_wizard reports STUCK after one Enter

This is the success signal for the finalize page AND for the already- booted-to-desktop state. If q.is_alive() is True and q.crashed() is False, the framebuffer is stable because there's nothing to advance — take a screenshot to confirm.

Pitfall: python3 test.py 2>&1 | tee out.log shows no progress

Python buffers stdout when not connected to a tty. Use python3 -u test.py or sprinkle flush=True on every important print(). drive_first_boot_wizard uses end="" for one of its prints to keep the page line on a single row, so its output won't appear until the line completes — that's normal.

Pitfall: monitor socket conflict

Only one client can hold the monitor socket at a time. If your test is running, do not also run a manual nc -U /tmp/rostest.sock from another terminal — it will steal commands or get them stolen. To inspect the running guest, take a screenshot via the test (or kill the test first).

Pitfall: qemu-system-x86_64 exited and you don't know why

-no-reboot makes any guest reset cause QEMU to exit. That's intentional — process exit IS the "guest rebooted" event. Use q.is_alive() to detect it, then q.restart(boot="c") to come back.

Pitfall: keys land in the wrong page because of stale buffer

spam_keys_until() may send a few extra ENTERs after the marker fires (the signal-to-stop loop runs at ~3 Hz). usetup buffers those and they advance the next pages too. The if q.log_count("SETUP_PAGE:WELCOME") == 0 guards in install_ntfs() exist for exactly this — they detect "we already advanced" and skip the redundant q.do("ret", ...).

End-to-end test the bitmap-TOCTOU fix

This is the script that verified the cluster-double-allocation fix works:

#!/usr/bin/env python3
"""Verify the volume bitmap R/M/W lock unblocks the registry-hive update."""
import sys
sys.path.insert(0, ".")
from ros_test import RosQemu, install_ntfs, monitor_copy, drive_first_boot_wizard

q = RosQemu(name="rostest", iso="build/bootcd.iso")
q.start(boot="d")

# --- Stage 1: text-mode setup ---
install_ntfs(q)
monitor_copy(q)
q.wait("SETUP_PAGE:SUCCESS", 120)
q.send("ret")  # reboot

# --- Stage 2: wait for the reboot, then drive the wizard ---
import time
while q.is_alive():
    time.sleep(1)
q.restart(boot="c")
drive_first_boot_wizard(q)

# --- Stage 3: verify second boot ---
if not q.is_alive():
    q.restart(boot="c")

hit = q.wait_any_new(
    ["Assertion failed", "Setup failed to create",
     "explorer.exe", "userinit", "WelcomeScreen"],
    timeout=600,
)

if hit and ("Assertion" in hit or "Setup failed" in hit):
    print(f"FAIL: {hit}")
    sys.exit(1)
print(f"PASS: reached {hit}")

When this script prints PASS: reached explorer.exe, the bitmap fix is verified end-to-end. The pre-fix qcow2 (kept untouched as evidence) shows the previous failure mode: the same script would report FAIL: Assertion failed at mft.c:3762 during stage 3 because cluster N was simultaneously claimed by a .lnk file's $DATA and a directory's $INDEX_ALLOCATION — confirmed by parsing the qcow2 directly with ntfs_parser.py.

#!/bin/bash
# ReactOS QEMU test utilities. Source this: . utils.sh
#
# DEPRECATED for new tests — prefer ros_test.py. This shell harness still
# works for one-shot manual driving (ros_start, ros_send, ros_wait, etc.),
# but everything ros_test.py does is event-driven on the serial log instead
# of sleep-driven, handles the reinstall path (UPGRADE_REPAIR + ESC), and is
# importable so you can adapt the test in real time from `python3 -c '...'`.
#
# See the module docstring in ros_test.py for recipes (fresh install,
# reinstall over an existing install, interactive driving, and how to assert
# that an NTFS driver fix actually fired by counting log events).
ROS_SOCK="${ROS_SOCK:-}"
ROS_LOG="${ROS_LOG:-}"
ROS_PID="${ROS_PID:-}"
ROS_DISK="${ROS_DISK:-}"
ROS_ISO="${ROS_ISO:-}"
ros_start() {
local name="${1:-rosN}"
local iso="${2:-build/bootcd.iso}"
local disk="${3:-}"
local boot="${4:-d}"
local fmt="raw"
# Kill previous QEMU using this disk, but not ourselves
local _oldpids=$(pgrep -f "qemu.*${name}" 2>/dev/null)
[ -n "$_oldpids" ] && kill -9 $_oldpids 2>/dev/null || true
rm -f /tmp/${name}.log /tmp/${name}.sock
if [ -n "$disk" ]; then
case "$disk" in
*.qcow2) fmt="qcow2" ;;
*) fmt="raw" ;;
esac
else
disk="/tmp/${name}.img"
rm -f "$disk"
truncate -s 10G "$disk"
fi
qemu-system-x86_64 \
-drive file="$disk",format=$fmt,if=ide \
-cdrom "$iso" \
-boot "$boot" -m 512 \
-serial file:/tmp/${name}.log \
-monitor unix:/tmp/${name}.sock,server,nowait \
-display vnc=:84 \
-no-reboot -nic none -enable-kvm -daemonize || return 1
ROS_SOCK="/tmp/${name}.sock"
ROS_LOG="/tmp/${name}.log"
ROS_PID=$(pgrep -f "$(basename "$disk")" | tail -1)
ROS_DISK="$disk"
ROS_ISO="$iso"
echo "QEMU started: PID=$ROS_PID VNC=:84 LOG=$ROS_LOG DISK=$disk"
}
ros_kill() {
[ -n "$ROS_PID" ] && kill -9 "$ROS_PID" 2>/dev/null
echo "killed"
}
ros_send() { ros_mon "sendkey $1"; }
ros_send_many() {
local key
for key in "$@"; do
ros_send "$key" >/dev/null 2>&1 || return 1
sleep 0.15
done
}
# Send any QEMU monitor command (drains prompt before/after, returns reply on stdout).
# Usage: ros_mon "info status" / ros_mon "screendump /tmp/x.ppm"
ros_mon() {
if [ -z "$ROS_SOCK" ] || [ ! -S "$ROS_SOCK" ]; then
echo "FAIL: monitor socket unavailable: $ROS_SOCK" >&2
return 1
fi
if command -v socat >/dev/null 2>&1; then
printf "%s\n" "$1" | socat -t1 - UNIX-CONNECT:"$ROS_SOCK" 2>/dev/null
return $?
fi
python3 - "$ROS_SOCK" "$1" <<'PY'
import socket, sys, time
sock_path, cmd = sys.argv[1], sys.argv[2]
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(3.0)
s.connect(sock_path)
# Drain banner / prior prompt
time.sleep(0.15)
try: s.recv(8192)
except Exception: pass
s.sendall((cmd + "\n").encode())
time.sleep(0.4)
try: sys.stdout.write(s.recv(16384).decode(errors="replace"))
except Exception: pass
s.close()
PY
}
# Take a screenshot via QEMU monitor and convert to PNG.
# Usage: ros_screenshot [out_basename] (default: ros_shot)
# Writes /tmp/<base>.ppm and /tmp/<base>.png. Echoes the PNG path.
ros_screenshot() {
local base="${1:-ros_shot}"
local ppm="/tmp/${base}.ppm"
local png="/tmp/${base}.png"
rm -f "$ppm" "$png"
ros_mon "screendump $ppm" >/dev/null || return 1
# Wait for the file to materialize
local i
for i in 1 2 3 4 5 6 7 8 9 10; do
[ -s "$ppm" ] && break
sleep 0.2
done
[ -s "$ppm" ] || { echo "FAIL: screendump produced no file" >&2; return 1; }
if command -v convert >/dev/null 2>&1; then
convert "$ppm" "$png" 2>/dev/null
else
python3 -c "from PIL import Image; Image.open('$ppm').save('$png')" 2>/dev/null
fi
[ -s "$png" ] && echo "$png" || echo "$ppm"
}
# md5 of current screen contents (used to detect visual change).
ros_screen_hash() {
local ppm="/tmp/.ros_hash.ppm"
rm -f "$ppm"
ros_mon "screendump $ppm" >/dev/null || return 1
local i
for i in 1 2 3 4 5; do [ -s "$ppm" ] && break; sleep 0.2; done
md5sum "$ppm" 2>/dev/null | awk '{print $1}'
}
ros_tail() {
local n="${1:-60}"
tail -n "$n" "$ROS_LOG"
}
ros_grep() {
grep -aE "${1:?pattern required}" "$ROS_LOG"
}
ros_log_size() {
stat -c '%s' "$ROS_LOG" 2>/dev/null || echo 0
}
ros_wait_log_growth() {
local timeout="${1:-5}"
local before
before=$(ros_log_size)
local deadline=$((SECONDS + timeout))
while [ $SECONDS -lt $deadline ]; do
local now
now=$(ros_log_size)
if [ "$now" -gt "$before" ]; then
return 0
fi
sleep 0.2
done
return 1
}
ros_wait_quiet() {
local quiet_for="${1:-3}"
local timeout="${2:-20}"
local deadline=$((SECONDS + timeout))
local last_size
local last_change=$SECONDS
last_size=$(ros_log_size)
while [ $SECONDS -lt $deadline ]; do
local now
now=$(ros_log_size)
if [ "$now" != "$last_size" ]; then
last_size="$now"
last_change=$SECONDS
elif [ $((SECONDS - last_change)) -ge "$quiet_for" ]; then
return 0
fi
sleep 0.2
done
return 1
}
ros_current_page() {
grep -a "SETUP_PAGE:" "$ROS_LOG" 2>/dev/null | tail -1 | sed 's/.*SETUP_PAGE://'
}
ros_current_selection() {
grep -a "SETUP_SELECTED:" "$ROS_LOG" 2>/dev/null | tail -1 | sed 's/.*SETUP_SELECTED://'
}
ros_wait_page() {
local page="${1:?page required}"
local timeout="${2:-10}"
ros_wait "SETUP_PAGE:${page}" "$timeout"
}
ros_fail_markers() {
grep -aE "kdb:>|Assertion failed|Page Fault|Exception Code|Entered debugger on last-chance exception|SaveDefaultUserHive\\(\\) failed|CopySystemProfile\\(\\) failed|Failed to create the implicit act ctx|Failed to lock volume|USA mismatch|Couldn't create file record|Rebooting now!" "$ROS_LOG" 2>/dev/null
}
ros_summary() {
echo "-- current page --"
ros_current_page
echo "-- current selection --"
ros_current_selection
echo "-- recent failures --"
ros_fail_markers | tail -20
}
# Wait for a pattern in the log. Returns 1 on timeout.
ros_wait() {
local pattern="$1"
local timeout="${2:-10}"
local start=$SECONDS
local deadline=$((SECONDS + timeout))
while ! grep -qa "$pattern" "$ROS_LOG" 2>/dev/null; do
if [ $SECONDS -ge $deadline ]; then
echo "FAIL: timeout(${timeout}s) waiting for: $pattern" >&2
tail -3 "$ROS_LOG" >&2
return 1
fi
sleep 0.3
done
echo " [${pattern}] ok ($((SECONDS - start))s)" >&2
return 0
}
# Wait for a NEW occurrence of pattern (counts before/after).
ros_wait_new() {
local pattern="$1"
local timeout="${2:-10}"
local start=$SECONDS
local before=$(grep -ca "$pattern" "$ROS_LOG" 2>/dev/null)
local deadline=$((SECONDS + timeout))
while true; do
local now=$(grep -ca "$pattern" "$ROS_LOG" 2>/dev/null)
if [ "$now" -gt "$before" ]; then
echo " [new ${pattern}] ok ($((SECONDS - start))s)" >&2
return 0
fi
if [ $SECONDS -ge $deadline ]; then
echo "FAIL: timeout(${timeout}s) waiting for new: $pattern" >&2
tail -3 "$ROS_LOG" >&2
return 1
fi
sleep 0.3
done
}
# Monadic: send key then wait for new pattern. Fails fast.
# Usage: ros_do KEY WAIT_PATTERN [TIMEOUT] [LABEL]
#
# IMPORTANT: 'before' count is captured BEFORE ros_send so that a page
# transition which happens synchronously (serial flush before the python
# monitor connection closes) is still detected as a new occurrence.
# The earlier implementation called ros_wait_new *after* ros_send, which
# snapshotted the already-incremented count and looped until timeout.
# Count lines matching $1 in $ROS_LOG. Always emits a single integer
# (0 if the file is missing or the pattern is absent). Do NOT use
# `grep -c ... || echo 0` — grep -c prints "0" AND exits 1 on zero
# matches, which would concatenate two "0"s under command substitution.
_ros_logcount() {
local n
n=$(grep -ca "$1" "$ROS_LOG" 2>/dev/null)
printf '%s' "${n:-0}"
}
ros_do() {
local key="$1" pattern="$2" timeout="${3:-10}" label="${4:-}"
local attempts=8
local i
[ -n "$label" ] && echo " >> $label" >&2
for i in $(seq 1 "$attempts"); do
local before
before=$(_ros_logcount "$pattern")
ros_send "$key" >/dev/null 2>&1 || { echo "FAIL: ros_send $key" >&2; return 1; }
local deadline=$((SECONDS + timeout))
while [ $SECONDS -lt $deadline ]; do
local now
now=$(_ros_logcount "$pattern")
if [ "$now" -gt "$before" ]; then
echo " [$pattern] ok" >&2
return 0
fi
sleep 0.3
done
if [ "$i" -lt "$attempts" ]; then
echo " >> retry ${i}/${attempts} for key '$key'" >&2
fi
done
return 1
}
ros_enter_bootcd() {
local timeout="${1:-30}"
local deadline=$((SECONDS + timeout))
# Send Enter ONE AT A TIME and check the log after every key. Bursting
# multiple Enters then checking only at the end (the previous behaviour)
# would walk the wizard several pages PAST SETUP_PAGE:LANGUAGE — the
# extra Enters get queued by the BIOS / FreeLDR / usetup and consumed
# by whatever page is on screen, silently advancing through WELCOME,
# INSTALL_INTRO, partition select, file-system select (defaulting to
# FAT), etc. By the time we noticed SETUP_PAGE:LANGUAGE we'd already
# be at FILE_COPY on the wrong filesystem.
while [ $SECONDS -lt $deadline ]; do
if grep -qa "SETUP_PAGE:LANGUAGE" "$ROS_LOG" 2>/dev/null; then
echo " [bootcd entered]" >&2
return 0
fi
ros_send ret >/dev/null 2>&1 || return 1
# Wait briefly for the key to take effect / log to update. This is
# a bounded poll, not a fixed sleep: every 100ms we re-check the
# log condition and bail out the moment LANGUAGE appears.
local mini=$((SECONDS + 1))
while [ $SECONDS -lt $mini ]; do
if grep -qa "SETUP_PAGE:LANGUAGE" "$ROS_LOG" 2>/dev/null; then
echo " [bootcd entered]" >&2
return 0
fi
sleep 0.1
done
done
echo "FAIL: boot menu did not reach SETUP_PAGE:LANGUAGE" >&2
return 1
}
# Check for crash/kdb/assertion. Returns 0 if crashed.
ros_crashed() {
grep -qa "kdb:>" "$ROS_LOG" 2>/dev/null
}
ros_stuck() {
! ros_wait_log_growth "${1:-3}"
}
# One-shot page transition with abort-on-wrong-page.
#
# Why this exists: ros_do retries the SAME key on timeout, which is wrong
# for a wizard — each retry presses Enter AGAIN, advancing the wizard one
# more page than intended. That's how a previous run accidentally landed
# on FORMAT_PARTITION with FAT preselected: 8 retries × Enter walked the
# script straight past SELECT_FILE_SYSTEM before the down-arrow code ran.
#
# This function presses the key ONCE, then polls until either:
# - the EXPECTED next page appears (success), or
# - any OTHER new page appears (abort: wrong transition), or
# - the timeout expires (abort: stuck)
#
# The "before" snapshot is captured BEFORE ros_send so a synchronous
# transition that flushes during the monitor close is still detected.
#
# Usage: ros_step KEY EXPECTED_PAGE [TIMEOUT] [LABEL]
ros_step() {
local key="$1" next="$2" timeout="${3:-15}" label="${4:-}"
local before_next before_any cur_before
cur_before=$(ros_current_page)
[ -n "$label" ] && echo " >> $label (${cur_before:-?} -> ${next})" >&2
before_next=$(_ros_logcount "SETUP_PAGE:${next}")
before_any=$(_ros_logcount "SETUP_PAGE:")
ros_send "$key" >/dev/null 2>&1 || { echo "FAIL: ros_send $key" >&2; return 1; }
local deadline=$((SECONDS + timeout))
while [ $SECONDS -lt $deadline ]; do
local now_next now_any
now_next=$(_ros_logcount "SETUP_PAGE:${next}")
if [ "$now_next" -gt "$before_next" ]; then
echo " [-> $next] ok" >&2
return 0
fi
now_any=$(_ros_logcount "SETUP_PAGE:")
if [ "$now_any" -gt "$before_any" ]; then
local landed
landed=$(ros_current_page)
if [ "$landed" != "$cur_before" ] && [ "$landed" != "$next" ]; then
echo "FAIL: expected ${cur_before:-?} -> ${next}, landed on ${landed}" >&2
return 2
fi
fi
sleep 0.2
done
echo "FAIL: timeout(${timeout}s) ${cur_before:-?} -> ${next}" >&2
return 1
}
# Walk a wizard list (down/up) until SETUP_SELECTED matches a substring.
# Sends one key per step, waits for a NEW SETUP_SELECTED line each time
# (or stops if the highlight is already on the desired item). Aborts if
# we move N times without ever matching.
#
# Usage: ros_select_until KEY MATCH_SUBSTRING [MAX_STEPS]
ros_select_until() {
local key="$1" match="$2" max="${3:-12}"
local i sel before now deadline
sel=$(ros_current_selection)
if [ -n "$sel" ] && echo "$sel" | grep -qa "$match"; then
echo " >> already selected: $sel" >&2
return 0
fi
for i in $(seq 1 "$max"); do
before=$(_ros_logcount "SETUP_SELECTED:")
ros_send "$key" >/dev/null 2>&1 || { echo "FAIL: ros_send $key" >&2; return 1; }
deadline=$((SECONDS + 3))
while [ $SECONDS -lt $deadline ]; do
now=$(_ros_logcount "SETUP_SELECTED:")
if [ "$now" -gt "$before" ]; then
break
fi
sleep 0.1
done
if [ "$now" -le "$before" ]; then
echo "FAIL: no SETUP_SELECTED after '$key' (step $i)" >&2
return 1
fi
sel=$(ros_current_selection)
if echo "$sel" | grep -qa "$match"; then
echo " >> selected: $sel" >&2
return 0
fi
done
echo "FAIL: '$match' not selected after $max '$key' presses (last: $sel)" >&2
return 1
}
# Fail-fast NTFS install pipeline: boot -> format NTFS -> copy files.
# Every step must succeed and land on the EXACT expected page or the whole
# pipeline aborts. Selection-list pages use ros_select_until to verify
# the highlighted item by name before pressing Enter.
ros_install_ntfs() {
echo "=== NTFS Install Pipeline ===" >&2
ros_wait "SETUP_PAGE:LANGUAGE" 30 || return 1
ros_step ret WELCOME 10 "LANGUAGE -> WELCOME" || return 1
ros_step ret INSTALL_INTRO 10 "WELCOME -> INSTALL_INTRO" || return 1
ros_step ret DEVICE_SETTINGS 10 "INSTALL_INTRO -> DEVICE_SETTINGS" || return 1
ros_step ret SELECT_PARTITION 10 "DEVICE_SETTINGS -> SELECT_PARTITION" || return 1
ros_step ret SELECT_FILE_SYSTEM 10 "SELECT_PARTITION -> SELECT_FILE_SYSTEM" || return 1
# Highlight "NTFS file system (quick format)" — the order in usetup is
# FAT (quick), FAT, NTFS (quick), NTFS — so two downs from the default.
# ros_select_until verifies by inspecting SETUP_SELECTED, so even if
# the order changes upstream we won't blast past the right entry.
ros_select_until down "NTFS file system (quick format)" 6 || return 1
# SELECT_FILE_SYSTEM -> FORMAT_PARTITION takes one Enter to confirm the
# FS choice. FORMAT_PARTITION takes one more Enter to actually start
# the format (FormatPartitionPage waits in a key loop on usetup.c:2548).
ros_step ret FORMAT_PARTITION 15 "SELECT_FILE_SYSTEM -> FORMAT_PARTITION" || return 1
# The next two transitions are AUTOMATIC — neither CHECK_FILE_SYSTEM
# (CheckFileSystemPage just displays and returns at usetup.c:2587) nor
# BOOTLOADER_SELECT_PAGE entry needs an extra key after the format Enter.
# If we sent Enter on CHECK_FILE_SYSTEM the keystroke would land on
# BOOTLOADER_SELECT and silently advance us a page too far.
ros_step ret CHECK_FILE_SYSTEM 60 "FORMAT_PARTITION -> CHECK_FILE_SYSTEM (formatting...)" || return 1
ros_wait "SETUP_PAGE:BOOTLOADER_SELECT" 60 || return 1
echo " [-> BOOTLOADER_SELECT] ok" >&2
ros_step ret INSTALL_DIRECTORY 10 "BOOTLOADER_SELECT -> INSTALL_DIRECTORY" || return 1
ros_step ret PREPARE_COPY 10 "INSTALL_DIRECTORY -> PREPARE_COPY" || return 1
# PREPARE_COPY -> FILE_COPY is also automatic (PrepareCopyPage returns
# FILE_COPY_PAGE at usetup.c:3090 without polling for input).
ros_wait "SETUP_PAGE:FILE_COPY" 30 || return 1
echo " [-> FILE_COPY] ok" >&2
echo "=== FILE_COPY started ===" >&2
}
# Monitor FILE_COPY progress. Returns 0 on success, 1 on crash/stall.
ros_monitor() {
local pct_timeout="${1:-120}"
local last_pct=""
local last_pct_time=$SECONDS
local start=$SECONDS
while true; do
if ros_crashed; then
echo "[$(date +%H:%M:%S)] CRASH detected after $((SECONDS - start))s!" >&2
grep -a "Assertion\|Page Fault\|Memory at\|Exception Code" "$ROS_LOG" | tail -3 >&2
return 1
fi
if grep -qa "Out of memory" "$ROS_LOG"; then
echo "[$(date +%H:%M:%S)] OOM after $((SECONDS - start))s!" >&2
return 1
fi
local pct=$(grep -a "PROGRESS:" "$ROS_LOG" | tail -1 | grep -o '[0-9]*%')
if [ -n "$pct" ] && [ "$pct" != "$last_pct" ]; then
echo "[$(date +%H:%M:%S)] Progress: $pct (total $((SECONDS - start))s)" >&2
last_pct="$pct"
last_pct_time=$SECONDS
fi
if grep -qa "SETUP_PAGE:REGISTRY\|SETUP_PAGE:BOOT_LOADER_INSTALL\|SETUP_PAGE:SUCCESS" "$ROS_LOG"; then
echo "[$(date +%H:%M:%S)] FILE_COPY complete ($((SECONDS - start))s)" >&2
return 0
fi
if [ $((SECONDS - last_pct_time)) -ge $pct_timeout ]; then
echo "[$(date +%H:%M:%S)] STALL: no progress for ${pct_timeout}s (last: $last_pct)" >&2
tail -3 "$ROS_LOG" >&2
return 1
fi
sleep 1
done
}
# Full pipeline: format NTFS, copy files, reboot from HD, check for crash.
ros_full_ntfs_test() {
local name="${1:-rostest}"
local iso="${2:-build/bootcd.iso}"
echo "=== Phase 1: Fresh install ===" >&2
ros_start "$name" "$iso" || { echo "FAIL: ros_start"; return 1; }
# FreeLDR boot menu needs Enter
ros_send ret; ros_send ret
ros_install_ntfs || { echo "FAIL: install"; ros_kill; return 1; }
ros_monitor 180 || { echo "FAIL: file copy"; ros_kill; return 1; }
# Wait for install to finish completely
ros_wait "SETUP_PAGE:SUCCESS" 60 || ros_wait "SETUP_PAGE:FLUSH" 60 || true
echo "=== Phase 2: Reboot from HD ===" >&2
ros_kill
# Restart booting from hard disk
local disk
case "$name" in
*) disk="/tmp/${name}.img" ;;
esac
[ -f "$disk" ] || disk="/tmp/${name}.qcow2"
rm -f /tmp/${name}.log /tmp/${name}.sock
local fmt="raw"
case "$disk" in *.qcow2) fmt="qcow2" ;; esac
qemu-system-x86_64 \
-drive file="$disk",format=$fmt,if=ide \
-boot c -m 512 \
-serial file:/tmp/${name}.log \
-monitor unix:/tmp/${name}.sock,server,nowait \
-display vnc=:84 \
-no-reboot -nic none -enable-kvm -daemonize || return 1
ROS_SOCK="/tmp/${name}.sock"
ROS_LOG="/tmp/${name}.log"
ROS_PID=$(pgrep -f "$(basename "$disk")" | tail -1)
echo " Booting from HD... (PID=$ROS_PID)" >&2
# Wait up to 60s for either successful boot or crash
local deadline=$((SECONDS + 60))
while [ $SECONDS -lt $deadline ]; do
if ros_crashed; then
echo "FAIL: BSOD/assertion on HD boot!" >&2
grep -a "Assertion\|kdb:>" "$ROS_LOG" | tail -5 >&2
return 1
fi
# Session 0 ready = win32k loaded successfully
if grep -qa "Session 0 is ready" "$ROS_LOG"; then
echo "PASS: Booted to session init" >&2
return 0
fi
sleep 1
done
echo "FAIL: boot timeout (no crash, no session)" >&2
tail -5 "$ROS_LOG" >&2
return 1
}
# Boot an existing disk image from HD (no CD), with GDB stub on :1234.
# Usage: ros_boot_hd [name] [disk] [memory_mb]
ros_boot_hd() {
local name="${1:-rosinst}"
local disk="${2:-/tmp/${name}.img}"
local mem="${3:-512}"
local fmt="raw"
case "$disk" in *.qcow2) fmt="qcow2" ;; esac
[ -f "$disk" ] || { echo "FAIL: disk $disk not found" >&2; return 1; }
local _oldpids=$(pgrep -f "qemu.*${name}" 2>/dev/null)
[ -n "$_oldpids" ] && kill -9 $_oldpids 2>/dev/null || true
rm -f /tmp/${name}.log /tmp/${name}.sock
qemu-system-x86_64 \
-drive file="$disk",format=$fmt,if=ide \
-boot c -m "$mem" \
-serial file:/tmp/${name}.log \
-monitor unix:/tmp/${name}.sock,server,nowait \
-display vnc=:84 \
-gdb tcp::1234 \
-no-reboot -nic none -enable-kvm -daemonize || return 1
ROS_SOCK="/tmp/${name}.sock"
ROS_LOG="/tmp/${name}.log"
ROS_PID=$(pgrep -f "$(basename "$disk")" | tail -1)
ROS_DISK="$disk"
echo "QEMU(HD) started: PID=$ROS_PID VNC=:84 GDB=:1234 LOG=$ROS_LOG DISK=$disk MEM=${mem}M"
}
# Boot an existing disk image with the CD attached, usually for reinstall or
# first-boot setup debugging. Optional boot order defaults to hard disk.
# Usage: ros_boot_disk_with_cd [name] [disk] [iso] [boot]
ros_boot_disk_with_cd() {
local name="${1:-rosinst}"
local disk="${2:-${ROS_DISK:-reactos.qcow2}}"
local iso="${3:-${ROS_ISO:-build/bootcd.iso}}"
local boot="${4:-c}"
local fmt="raw"
case "$disk" in *.qcow2) fmt="qcow2" ;; esac
[ -f "$disk" ] || { echo "FAIL: disk $disk not found" >&2; return 1; }
[ -f "$iso" ] || { echo "FAIL: iso $iso not found" >&2; return 1; }
local _oldpids=$(pgrep -f "qemu.*${name}" 2>/dev/null)
[ -n "$_oldpids" ] && kill -9 $_oldpids 2>/dev/null || true
rm -f /tmp/${name}.log /tmp/${name}.sock
qemu-system-x86_64 \
-drive file="$disk",format=$fmt,if=ide \
-cdrom "$iso" \
-boot "$boot" -m 512 \
-serial file:/tmp/${name}.log \
-monitor unix:/tmp/${name}.sock,server,nowait \
-display vnc=:84 \
-gdb tcp::1234 \
-no-reboot -nic none -enable-kvm -daemonize || return 1
ROS_SOCK="/tmp/${name}.sock"
ROS_LOG="/tmp/${name}.log"
ROS_PID=$(pgrep -f "$(basename "$disk")" | tail -1)
ROS_DISK="$disk"
ROS_ISO="$iso"
echo "QEMU(HD+CD) started: PID=$ROS_PID VNC=:84 GDB=:1234 LOG=$ROS_LOG DISK=$disk ISO=$iso"
}
# Press Enter on a wizard page and report whether the serial log advanced.
# Usage: ros_advance_page [timeout]
ros_advance_page() {
local timeout="${1:-2}"
local before
before=$(ros_current_page)
ros_send ret >/dev/null 2>&1 || return 1
local deadline=$((SECONDS + timeout))
while [ $SECONDS -lt $deadline ]; do
local now
now=$(ros_current_page)
if [ -n "$now" ] && [ "$now" != "$before" ]; then
echo " [page ${before:-?} -> $now]" >&2
return 0
fi
sleep 0.2
done
echo " [page unchanged: ${before:-unknown}]" >&2
return 1
}
# Print a compact diagnosis for whatever the current boot/install is doing.
ros_diag() {
echo "LOG=$ROS_LOG"
echo "PAGE=$(ros_current_page)"
echo "SELECTED=$(ros_current_selection)"
if ros_crashed; then
echo "STATE=crashed"
elif ros_stuck 2; then
echo "STATE=stalled"
else
echo "STATE=running"
fi
ros_fail_markers | tail -10
}
# Advance one step in a GUI wizard (reactos.exe second-stage etc).
# Tries Enter; if the screen doesn't change, presses Tab and retries (focus
# on a dropdown/list eats the Enter — Tab moves focus to the Next button).
# Usage: ros_wizard_advance [label] [max_tabs]
ros_wizard_advance() {
local label="${1:-step}"
local max_tabs="${2:-6}"
local h0=$(ros_screen_hash)
local i
for i in $(seq 0 "$max_tabs"); do
ros_send ret >/dev/null
sleep 1.2
local h1=$(ros_screen_hash)
if [ "$h1" != "$h0" ]; then
echo " >> $label: advanced (after $i tab(s))" >&2
return 0
fi
ros_send tab >/dev/null
sleep 0.3
done
echo "FAIL: $label stuck (no visual change)" >&2
return 1
}
# Walk N pages of a GUI wizard, snapshotting each.
# Usage: ros_wizard_walk N [snapshot_prefix]
# Writes /tmp/<prefix>_<i>.png for each page reached.
ros_wizard_walk() {
local n="${1:-10}"
local prefix="${2:-wiz}"
local i
for i in $(seq 1 "$n"); do
ros_screenshot "${prefix}_${i}" >/dev/null
ros_wizard_advance "page$i" 6 || { echo " >> stopped at page$i" >&2; return 1; }
done
ros_screenshot "${prefix}_final" >/dev/null
}
# ============================================================
# Video recording pipeline (QEMU screendump → mp4)
# ============================================================
# ros_record_start [outdir]
# Start a background frame-capture loop that talks to $ROS_SOCK via the
# QEMU monitor and writes numbered PPM frames to outdir. Deduplicates
# identical frames so idle screens don't bloat the output. Robust to
# QEMU restarts (phase transitions): if the monitor socket disappears
# it just waits for it to reappear.
#
# ros_record_stop [outdir]
# Touch a sentinel that tells the loop to exit, then waits for it.
#
# ros_encode_video [outdir] [out.mp4] [fps]
# Normalizes all PPM frames to the largest dimensions seen (letterbox
# padded), then encodes to h264 mp4 via gst-launch-1.0. Defaults: the
# same directory ros_record_start wrote to, fps=10. Uses ImageMagick
# `convert` for normalization and GStreamer for encoding.
#
# Typical use:
# ros_start rosvid build/bootcd.iso
# ros_record_start
# # ... drive the install / wizard, restart QEMU between phases ...
# ros_record_stop
# ros_encode_video /tmp/rosvid_frames /tmp/rosvid.mp4 10
ROS_FRAMES_DIR_DEFAULT="/tmp/rosvid_frames"
ROS_FRAMER_PY="/tmp/ros_framer.py"
ROS_FRAMER_PID_FILE="/tmp/ros_framer.pid"
ROS_FRAMER_STOP="/tmp/ros_framer.stop"
ROS_FRAMER_LOG="/tmp/ros_framer.log"
_ros_write_framer() {
cat > "$ROS_FRAMER_PY" <<'PY'
#!/usr/bin/env python3
"""Background screendump loop. Args: <sock_path> <outdir> <stop_sentinel>.
Writes numbered PPM frames, dedupes identical frames, survives QEMU restarts.
"""
import hashlib
import os
import socket
import sys
import time
sock_path, outdir, stop_sentinel = sys.argv[1], sys.argv[2], sys.argv[3]
os.makedirs(outdir, exist_ok=True)
for f in os.listdir(outdir):
if f.endswith('.ppm'):
try:
os.remove(os.path.join(outdir, f))
except OSError:
pass
n = 0
last_hash = None
interval = float(os.environ.get('ROS_FRAMER_INTERVAL', '0.8'))
while not os.path.exists(stop_sentinel):
if not os.path.exists(sock_path):
time.sleep(0.5)
continue
try:
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(3.0)
s.connect(sock_path)
time.sleep(0.08)
try:
s.recv(8192)
except Exception:
pass
frame = os.path.join(outdir, f'f_{n:06d}.ppm')
s.sendall(f'screendump {frame}\n'.encode())
time.sleep(0.4)
try:
s.recv(8192)
except Exception:
pass
s.close()
if os.path.exists(frame) and os.path.getsize(frame) > 1000:
with open(frame, 'rb') as fh:
h = hashlib.md5(fh.read()).hexdigest()
if h == last_hash:
os.remove(frame)
else:
last_hash = h
n += 1
except Exception:
pass
time.sleep(interval)
print(f'[framer] captured {n} unique frames', file=sys.stderr)
PY
}
ros_record_start() {
local outdir="${1:-$ROS_FRAMES_DIR_DEFAULT}"
if [ -z "$ROS_SOCK" ] || [ ! -S "$ROS_SOCK" ]; then
echo "FAIL: ROS_SOCK unset or not a socket (start QEMU first with ros_start)" >&2
return 1
fi
if [ -f "$ROS_FRAMER_PID_FILE" ] && kill -0 "$(cat "$ROS_FRAMER_PID_FILE")" 2>/dev/null; then
echo "FAIL: framer already running (PID $(cat "$ROS_FRAMER_PID_FILE"))" >&2
return 1
fi
_ros_write_framer
rm -f "$ROS_FRAMER_STOP" "$ROS_FRAMER_LOG"
python3 "$ROS_FRAMER_PY" "$ROS_SOCK" "$outdir" "$ROS_FRAMER_STOP" \
> "$ROS_FRAMER_LOG" 2>&1 &
echo "$!" > "$ROS_FRAMER_PID_FILE"
echo "Framer started: PID=$! outdir=$outdir log=$ROS_FRAMER_LOG" >&2
}
ros_record_stop() {
if [ ! -f "$ROS_FRAMER_PID_FILE" ]; then
echo "FAIL: no framer running" >&2
return 1
fi
local pid=$(cat "$ROS_FRAMER_PID_FILE")
touch "$ROS_FRAMER_STOP"
# Give it time to finish the current iteration
local i
for i in 1 2 3 4 5 6 7 8 9 10; do
kill -0 "$pid" 2>/dev/null || break
sleep 0.5
done
if kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null
fi
rm -f "$ROS_FRAMER_PID_FILE" "$ROS_FRAMER_STOP"
# Report frame count
local outdir="${1:-$ROS_FRAMES_DIR_DEFAULT}"
local count=$(ls "$outdir"/*.ppm 2>/dev/null | wc -l)
echo "Framer stopped: $count frames in $outdir" >&2
cat "$ROS_FRAMER_LOG" 2>/dev/null >&2
}
ros_encode_video() {
local outdir="${1:-$ROS_FRAMES_DIR_DEFAULT}"
local out="${2:-/tmp/rosvid.mp4}"
local fps="${3:-10}"
local crf="${4:-32}"
[ -d "$outdir" ] || { echo "FAIL: $outdir not found" >&2; return 1; }
local count=$(ls "$outdir"/f_*.ppm 2>/dev/null | wc -l)
if [ "$count" -eq 0 ]; then
echo "FAIL: no frames in $outdir" >&2
return 1
fi
# Detect max dims across all frames (text mode 720x400 mixed with GUI 800x600)
local dims=$(identify -format "%w %h\n" "$outdir"/f_*.ppm 2>/dev/null | \
awk 'BEGIN{mw=0;mh=0} {if($1>mw)mw=$1; if($2>mh)mh=$2} END{print mw"x"mh}')
echo "Normalizing $count frames to $dims..." >&2
# Normalize into a staging dir with renumbered contiguous names
local norm="${outdir}.norm"
rm -rf "$norm"; mkdir -p "$norm"
local i=0
for f in "$outdir"/f_*.ppm; do
local dst=$(printf "%s/f_%06d.ppm" "$norm" "$i")
convert "$f" -resize "$dims" -background black -gravity center -extent "$dims" "$dst" 2>/dev/null
i=$((i+1))
done
if ! command -v gst-launch-1.0 >/dev/null 2>&1; then
echo "FAIL: gst-launch-1.0 not found" >&2
return 1
fi
# CRF + stillimage tune + faststart: ~50% smaller than constant-bitrate
# encoding for screen content, and the moov atom lands at the front so
# GitHub's comment renderer can start playback without a full download.
rm -f "$out"
gst-launch-1.0 -q -e \
multifilesrc location="${norm}/f_%06d.ppm" index=0 \
caps="image/x-portable-pixmap,framerate=(fraction)${fps}/1" \
! pnmdec \
! videoconvert \
! x264enc pass=quant quantizer=$crf speed-preset=veryslow \
bframes=3 cabac=true tune=stillimage key-int-max=$((fps*3)) \
! h264parse \
! mp4mux faststart=true \
! filesink location="$out" 2>&1 | tail -4 >&2
if [ -s "$out" ]; then
local duration=$(echo "scale=1; $count / $fps" | bc 2>/dev/null)
local size=$(du -h "$out" | cut -f1)
echo "Encoded: $out ($size, ~${duration}s at ${fps}fps, $count frames, crf=$crf)" >&2
return 0
fi
echo "FAIL: encode produced no file" >&2
return 1
}
echo "utils.sh loaded. Commands: ros_start, ros_kill, ros_send, ros_mon, ros_do, ros_wait, ros_wait_new, ros_install_ntfs, ros_monitor, ros_full_ntfs_test, ros_boot_hd, ros_screenshot, ros_screen_hash, ros_wizard_advance, ros_wizard_walk, ros_record_start, ros_record_stop, ros_encode_video"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment