Crusader_Decomp/patch_crusader_cheat_menu.ps1

1448 lines
64 KiB
PowerShell
Raw Normal View History

param(
2026-03-29 01:14:09 +01:00
[ValidateSet('1', '2', '3', '4', '5', '6', 'candidate-i', 'candidate-j', 'candidate-m', 'candidate-n', 'candidate-o', 'candidate-p', 'restore', 'exit', 'map-redirect', 'map-default')]
[string]$Choice,
2026-03-29 01:14:09 +01:00
[ValidateRange(0, 65535)]
[int]$FirstMissionMap,
[ValidateRange(0, 65535)]
[Nullable[int]]$FirstMissionEgg,
[string]$ExePath = $(
if (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'CRUSADER.EXE')) {
Join-Path $PSScriptRoot 'CRUSADER.EXE'
}
elseif (Test-Path -LiteralPath 'F:\Apps\Crusader No Remorse\CRUSADER.EXE') {
'F:\Apps\Crusader No Remorse\CRUSADER.EXE'
}
else {
Join-Path $PSScriptRoot 'CRUSADER.EXE'
}
)
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$exePath = $ExePath
$statePath = Join-Path $PSScriptRoot 'patch_crusader_cheat_menu.state.json'
$sites = @{
CtrlQDebuggerInit = @{
Label = 'Ctrl+Q debugger-state init body'
Offset = 0xC970D
Original = [byte[]](
0xA0, 0x4F, 0x60, 0xB4, 0x00, 0xF7, 0xD8, 0x1B, 0xC0, 0x40, 0xA2, 0x4F, 0x60, 0x80, 0x3E, 0x4F,
0x60, 0x00, 0x74, 0x47, 0x6A, 0xFF, 0x6A, 0xFF, 0xC4, 0x1E, 0xD0, 0x4C, 0x26, 0x8A, 0x47, 0x05,
0x50, 0x1E, 0x68, 0xD2, 0x60, 0x6A, 0x00, 0x6A, 0x00, 0x83, 0xEC, 0x06, 0xC7, 0x86, 0x76, 0xFF,
0x00, 0x00, 0x8B, 0x86, 0x76, 0xFF, 0xF7, 0xD0, 0x89, 0x86, 0x78, 0xFF, 0xC6, 0x86, 0x7A, 0xFF,
0x00, 0x6A, 0x00, 0x6A, 0x00, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x14, 0x52, 0x50, 0x9A,
0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x08, 0x5F, 0x5E, 0xC9, 0xCB, 0x6A, 0xFF, 0x6A, 0xFF, 0xC4,
0x1E, 0xD0, 0x4C, 0x26, 0x8A, 0x47, 0x05, 0x50, 0x1E, 0x68, 0xEE, 0x60, 0x6A, 0x00, 0x6A, 0x00,
0x83, 0xEC, 0x06, 0xC7
)
Patched = [byte[]](
0xA1, 0x9C, 0x65, 0x0B, 0x06, 0x9E, 0x65, 0x74, 0x10, 0xC4, 0x1E, 0x9C, 0x65, 0xC6, 0x47, 0x75,
0x00, 0xC6, 0x47, 0x74, 0x01, 0x5F, 0x5E, 0xC9, 0xCB, 0x6A, 0x00, 0x6A, 0x00, 0xE9, 0x25, 0x00,
0x55, 0x8B, 0xEC, 0xA1, 0x9C, 0x65, 0x39, 0x46, 0x06, 0x75, 0x16, 0xA1, 0x9E, 0x65, 0x39, 0x46,
0x08, 0x75, 0x0E, 0xC4, 0x5E, 0x06, 0xC7, 0x47, 0x74, 0x00, 0x00, 0x6A, 0x00, 0x6A, 0x00, 0xEB,
0x0E, 0x5D, 0xCB, 0x90, 0x90, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x04, 0xEB, 0x0A, 0x9A,
0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x04, 0x5D, 0xCB, 0x0B, 0xC2, 0x74, 0x13, 0xA3, 0x9C, 0x65,
0x89, 0x16, 0x9E, 0x65, 0x89, 0xC3, 0x8E, 0xC2, 0xC6, 0x47, 0x75, 0x00, 0xC6, 0x47, 0x74, 0x01,
0x5F, 0x5E, 0xC9, 0xCB
)
LegacyPatched = [byte[]](
0xA1, 0x9C, 0x65, 0x0B, 0x06, 0x9E, 0x65, 0x75, 0x52, 0x31, 0xC0, 0x50, 0x50, 0xEB, 0x36,
0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90,
0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90,
0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90,
0x90, 0x90, 0x90, 0x90, 0x90, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x04, 0x0B, 0xC2, 0x75,
0x03, 0xE9, 0x46, 0x06, 0xA3, 0x9C, 0x65, 0x89, 0x16, 0x9E, 0x65, 0xC4, 0x1E, 0x9C, 0x65, 0xC6,
0x47, 0x75, 0x01, 0xC6, 0x47, 0x74, 0x00, 0xC7, 0x47, 0x76, 0x00, 0x00, 0xC7, 0x47, 0x78, 0x00,
0x00, 0xE9, 0x26, 0x06, 0xC7
)
LegacyPatchedVariants = @(
[byte[]](
0xA1, 0x9C, 0x65, 0x0B, 0x06, 0x9E, 0x65, 0x75, 0x5E, 0x31, 0xC0, 0x50, 0x50, 0x90, 0x90, 0x90,
0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90,
0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90,
0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90,
0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x04,
0xEB, 0x0D, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x04, 0x5F, 0x5E, 0xC9, 0xCB, 0x0B, 0xC2,
0x74, 0xF8, 0xA3, 0x9C, 0x65, 0x89, 0x16, 0x9E, 0x65, 0x6A, 0x00, 0x6A, 0x00, 0xEB, 0xE2, 0x90,
0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0xC7
),
[byte[]](
0xA1, 0x9C, 0x65, 0x0B, 0x06, 0x9E, 0x65, 0x75, 0x5E, 0x31, 0xC0, 0x50, 0x50, 0x90, 0x90, 0x90,
0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90,
0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90,
0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90,
0x90, 0x90, 0x90, 0x90, 0x90, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x04, 0xEB, 0x0D, 0x9A,
0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x04, 0x5F, 0x5E, 0xC9, 0xCB, 0x0B, 0xC2, 0x74, 0xF7, 0xA3,
0x9C, 0x65, 0x89, 0x16, 0x9E, 0x65, 0x6A, 0x00, 0x6A, 0x00, 0xEB, 0xE2, 0x90, 0x90, 0x90, 0x90,
0x90, 0x90, 0x90, 0xEC, 0x06, 0xC7
),
[byte[]](
0xA1, 0x9C, 0x65, 0x0B, 0x06, 0x9E, 0x65, 0x75, 0x55, 0x31, 0xC0, 0x50, 0x50, 0x90, 0x90, 0x90,
0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90,
0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90,
0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90,
0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x04,
0x52, 0x50, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x04, 0xA3, 0x9C, 0x65, 0x89, 0x16, 0x9E,
0x65, 0xC4, 0x1E, 0x9C, 0x65, 0xC7, 0x07, 0xAF, 0x65, 0xC6, 0x47, 0x75, 0x01, 0xC6, 0x47, 0x74,
0x00, 0x5F, 0x5E, 0xC9, 0xCB, 0x90, 0x90
)
)
LegacyPatchedFixupState = 'Any'
OriginalPatterns = @(
@('A0', '4F', '60', 'B4', '00', 'F7', 'D8', '1B', 'C0', '40', 'A2', '4F', '60', '80', '3E', '4F', '60', '00', '74', '47')
)
Fixup = @{
OperandOffset = 0xC9753
OriginalTargetSeg = 107
OriginalTargetOffset = 0x0046
PatchedTargetSeg = 130
PatchedTargetOffset = 0x0000
}
}
DebuggerCallback = @{
Label = 'legacy secondary call cleanup'
Offset = 0xC975D
Original = [byte[]](0xFF, 0xFF, 0x00, 0x00)
Patched = [byte[]](0xFF, 0xFF, 0x00, 0x00)
OriginalPatterns = @(
@('FF', 'FF', '00', '00')
)
Fixup = @{
OperandOffset = 0xC975D
OriginalTargetSeg = 101
OriginalTargetOffset = 0x1588
PatchedTargetSeg = 101
PatchedTargetOffset = 0x1588
}
}
PrivateBreakpointMethod0 = @{
Label = 'private break callback slot'
Offset = 0xEA328
Original = [byte[]](0xFF, 0xFF, 0x00, 0x00)
Patched = [byte[]](0xFF, 0xFF, 0x00, 0x00)
OriginalPatterns = @(
@('FF', 'FF', '00', '00')
)
Fixup = @{
OperandOffset = 0xEA328
OriginalTargetSeg = 133
OriginalTargetOffset = 0x1162
PatchedTargetSeg = 117
PatchedTargetOffset = 0x0086
}
}
PrivateBreakpointMethod1 = @{
Label = 'private helper callback slot'
Offset = 0xEA32C
Original = [byte[]](0xFF, 0xFF, 0x00, 0x00)
Patched = [byte[]](0xFF, 0xFF, 0x00, 0x00)
OriginalPatterns = @(
@('FF', 'FF', '00', '00')
)
Fixup = @{
OperandOffset = 0xEA32C
OriginalTargetSeg = 133
OriginalTargetOffset = 0x1278
PatchedTargetSeg = 130
PatchedTargetOffset = 0x0474
}
}
LegacyBreakpointCallback = @{
Label = 'legacy seg1408 break callback target'
Offset = 0xEA1AB
Original = [byte[]](0xFF, 0xFF, 0x00, 0x00)
Patched = [byte[]](0xFF, 0xFF, 0x00, 0x00)
OriginalPatterns = @(
@('FF', 'FF', '00', '00')
)
Fixup = @{
OperandOffset = 0xEA1AB
OriginalTargetSeg = 130
OriginalTargetOffset = 0x046F
PatchedTargetSeg = 117
PatchedTargetOffset = 0x0086
}
}
CallbackGuardCode = @{
Label = 'seg1408 break-next dispatch patch'
Offset = 0xCEAD3
Original = [byte[]](0x26, 0x8B, 0x1F, 0xFF, 0x1F)
Patched = [byte[]](0xFF, 0x1E, 0x97, 0x65, 0x90)
OriginalPatterns = @(
@('26', '8B', '1F', 'FF', '1F')
)
}
CallbackTargetSlot = @{
Label = 'break-next private stub target slot'
Offset = 0xEA197
Original = [byte[]](0xFF, 0xFF, 0x00, 0x00)
Patched = [byte[]](0xFF, 0xFF, 0x00, 0x00)
OriginalPatterns = @(
@('FF', 'FF', '00', '00')
)
Fixup = @{
OperandOffset = 0xEA197
OriginalTargetSeg = 5
OriginalTargetOffset = 0x08D2
PatchedTargetSeg = 131
PatchedTargetOffset = 0x2318
}
}
InterpreterBreakCall = @{
Label = 'interpreter debugger break callsite'
Offset = 0xCFAB5
Original = [byte[]](0x9A, 0xFF, 0xFF, 0x00, 0x00)
Patched = [byte[]](0x9A, 0xFF, 0xFF, 0x00, 0x00)
OriginalPatterns = @(
@('9A', 'FF', 'FF', '00', '00')
)
Fixup = @{
OperandOffset = 0xCFAB6
OriginalTargetSeg = 130
OriginalTargetOffset = 0x0053
PatchedTargetSeg = 131
PatchedTargetOffset = 0x232D
}
}
Hook = @{
Label = 'Rejected direct cheat hook site'
Offset = 0x70D75
Original = [byte[]](0x68, 0x03, 0x01, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x02)
Patched = [byte[]](0x68, 0x03, 0x01, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x02)
LegacyBadPatched = [byte[]](0x9A, 0x86, 0x9A, 0x0B, 0x00, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90)
OriginalPatterns = @(
@('68', '03', '01', '9A', 'FF', 'FF', '00', '00', '83', 'C4', '02')
)
Fixup = @{
OperandOffset = 0x70D79
OriginalTargetSeg = 92
OriginalTargetOffset = 0x0476
PatchedTargetSeg = 117
PatchedTargetOffset = 0x0086
RetailSourceSegmentIndex = 39
RetailChainOffset = 0x2B79
RetailEntryOffset = 0x71D68
}
}
Wrapper = @{
Label = 'Rejected current-slot wrapper arg site'
Offset = 0xB9A8D
Original = [byte[]](0x6A, 0x01, 0xFF, 0x76, 0x08, 0xFF, 0x76, 0x06)
Patched = [byte[]](0x6A, 0x01, 0x6A, 0x00, 0x6A, 0x00, 0x90, 0x90)
OriginalPatterns = @(
@('6A', '01', 'FF', '76', '08', 'FF', '76', '06')
)
}
LegacyDeferredHook = @{
Label = 'Rejected deferred-event hook site'
Offset = 0xC99DD
Original = [byte[]](0x68, 0x03, 0x01, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x02)
Patched = [byte[]](0x68, 0x03, 0x01, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x02)
OriginalPatterns = @(
@('68', '03', '01', '9A', 'FF', 'FF', '00', '00', '83', 'C4', '02')
)
Fixup = @{
OperandOffset = 0xC99E1
OriginalTargetSeg = 92
OriginalTargetOffset = 0x0476
PatchedTargetSeg = 117
PatchedTargetOffset = 0x020D
}
}
LegacyDeferredWrapper = @{
Label = 'Rejected modal wrapper arg site'
Offset = 0xB9C48
Original = [byte[]](0x6A, 0x00, 0xFF, 0x76, 0x08, 0xFF, 0x76, 0x06)
Patched = [byte[]](0x6A, 0x00, 0x6A, 0x00, 0x6A, 0x00, 0x90, 0x90)
OriginalPatterns = @(
@('6A', '00', 'FF', '76', '08', 'FF', '76', '06')
)
}
2026-03-29 01:14:09 +01:00
FirstMissionStart = @{
Label = 'Fresh-game startup map/egg selector'
Offset = 0x42041
Original = [byte[]](0x6A, 0x01, 0x66, 0x68, 0x01, 0x00, 0x1E, 0x00)
DefaultMap = 1
DefaultEgg = 0x1E
}
}
$candidateProfiles = [ordered]@{
'candidate-o' = @{
MenuKey = '1'
Label = 'Candidate O'
ArmMode = 'interpreter-callsite'
UiTargetSeg = 117
UiTargetOffset = 0x020D
PatchCurrentUnitWrapper = $false
PatchModalWrapper = $true
Summary = 'interpreter callsite retarget -> corrected private modal stub with zeroed modal-wrapper args'
}
'candidate-p' = @{
MenuKey = '2'
Label = 'Candidate P'
ArmMode = 'interpreter-callsite'
UiTargetSeg = 117
UiTargetOffset = 0x0086
PatchCurrentUnitWrapper = $true
PatchModalWrapper = $false
Summary = 'interpreter callsite retarget -> corrected private current-unit stub with zeroed current-slot args'
}
}
$script:ctrlQPatchedTemplate = [byte[]]$sites.CtrlQDebuggerInit.Patched.Clone()
$script:configuredProfileKey = 'candidate-o'
function Find-ByteSequenceOffset {
param(
[byte[]]$Bytes,
[byte[]]$Pattern
)
$lastStart = $Bytes.Length - $Pattern.Length
for ($start = 0; $start -le $lastStart; $start++) {
$matched = $true
for ($index = 0; $index -lt $Pattern.Length; $index++) {
if ($Bytes[$start + $index] -ne $Pattern[$index]) {
$matched = $false
break
}
}
if ($matched) {
return $start
}
}
throw 'Could not find the private-vtable base immediate inside the Ctrl+Q patch template.'
}
function New-CtrlQPatchedBytes {
param([string]$ArmMode)
switch ($ArmMode) {
'interpreter-callsite' {
return [byte[]](
0xA1, 0x9C, 0x65, 0x0B, 0x06, 0x9E, 0x65, 0x74, 0x10, 0xC4, 0x1E,
0x9C, 0x65, 0xC6, 0x47, 0x75, 0x00, 0xC6, 0x47, 0x74, 0x01, 0x5F,
0x5E, 0xC9, 0xCB, 0x6A, 0x00, 0x6A, 0x00, 0xE9, 0x25, 0x00, 0x55,
0x8B, 0xEC, 0xA1, 0x9C, 0x65, 0x39, 0x46, 0x06, 0x75, 0x16, 0xA1,
0x9E, 0x65, 0x39, 0x46, 0x08, 0x75, 0x0E, 0xC4, 0x5E, 0x06, 0xC7,
0x47, 0x74, 0x00, 0x00, 0x6A, 0x00, 0x6A, 0x00, 0xEB, 0x0E, 0x5D,
0xCB, 0x90, 0x90, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x04,
0xEB, 0x0A, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x04, 0x5D,
0xCB, 0x0B, 0xC2, 0x74, 0x13, 0xA3, 0x9C, 0x65, 0x89, 0x16, 0x9E,
0x65, 0x89, 0xC3, 0x8E, 0xC2, 0xC6, 0x47, 0x75, 0x00, 0xC6, 0x47,
0x74, 0x01, 0x5F, 0x5E, 0xC9, 0xCB
)
}
default {
throw "Unsupported arm mode '$ArmMode'."
}
}
}
function Get-ConfiguredCandidateProfile {
return $candidateProfiles[$script:configuredProfileKey]
}
function Set-ConfiguredCandidateProfile {
param([string]$ProfileKey)
if (-not $candidateProfiles.Contains($ProfileKey)) {
throw "Unknown candidate profile '$ProfileKey'."
}
2026-03-29 01:14:09 +01:00
$candidateProfile = $candidateProfiles[$ProfileKey]
$sites.CtrlQDebuggerInit.Patched = New-CtrlQPatchedBytes -ArmMode ([string]$candidateProfile.ArmMode)
$sites.DebuggerCallback.Fixup.PatchedTargetSeg = [int]$candidateProfile.UiTargetSeg
$sites.DebuggerCallback.Fixup.PatchedTargetOffset = [int]$candidateProfile.UiTargetOffset
$script:configuredProfileKey = $ProfileKey
}
function Test-CandidateApplied {
param(
[byte[]]$FileBytes,
[string]$ProfileKey
)
$previousProfileKey = $script:configuredProfileKey
try {
Set-ConfiguredCandidateProfile -ProfileKey $ProfileKey
return (
(Get-SiteState -FileBytes $FileBytes -Site $sites.CtrlQDebuggerInit) -eq 'Patched' -and
(Get-SiteState -FileBytes $FileBytes -Site $sites.InterpreterBreakCall) -eq 'Patched' -and
(Get-SiteState -FileBytes $FileBytes -Site $sites.DebuggerCallback) -eq 'Patched' -and
(Get-SiteState -FileBytes $FileBytes -Site $sites.CallbackGuardCode) -eq 'Original' -and
(Get-SiteState -FileBytes $FileBytes -Site $sites.CallbackTargetSlot) -eq 'Original' -and
(Get-SiteState -FileBytes $FileBytes -Site $sites.Wrapper) -eq $(if ($candidateProfiles[$ProfileKey].PatchCurrentUnitWrapper) { 'Patched' } else { 'Original' }) -and
(Get-SiteState -FileBytes $FileBytes -Site $sites.LegacyDeferredWrapper) -eq $(if ($candidateProfiles[$ProfileKey].PatchModalWrapper) { 'Patched' } else { 'Original' })
)
}
finally {
Set-ConfiguredCandidateProfile -ProfileKey $previousProfileKey
}
}
function Get-AppliedCandidateProfileKey {
param([byte[]]$FileBytes)
foreach ($profileKey in $candidateProfiles.Keys) {
if (Test-CandidateApplied -FileBytes $FileBytes -ProfileKey $profileKey) {
return $profileKey
}
}
return $null
}
Set-ConfiguredCandidateProfile -ProfileKey $script:configuredProfileKey
function Format-HexBytes {
param([byte[]]$Bytes)
return (($Bytes | ForEach-Object { $_.ToString('X2') }) -join ' ')
}
function Get-ByteSlice {
param(
[byte[]]$Bytes,
[int]$Offset,
[int]$Count
)
if ($Offset -lt 0 -or ($Offset + $Count) -gt $Bytes.Length) {
throw "Requested byte range 0x{0:X}-0x{1:X} is outside the file." -f $Offset, ($Offset + $Count - 1)
}
$slice = New-Object byte[] $Count
[Array]::Copy($Bytes, $Offset, $slice, 0, $Count)
return $slice
}
function Get-U16Le {
param(
[byte[]]$Bytes,
[int]$Offset
)
return [BitConverter]::ToUInt16($Bytes, $Offset)
}
function Get-U32Le {
param(
[byte[]]$Bytes,
[int]$Offset
)
return [BitConverter]::ToUInt32($Bytes, $Offset)
}
function Set-U16Le {
param(
[byte[]]$Bytes,
[int]$Offset,
[int]$Value
)
$raw = [BitConverter]::GetBytes([UInt16]$Value)
[Array]::Copy($raw, 0, $Bytes, $Offset, 2)
}
function Test-ByteArrayEqual {
param(
[byte[]]$Left,
[byte[]]$Right
)
if ($Left.Length -ne $Right.Length) {
return $false
}
for ($i = 0; $i -lt $Left.Length; $i++) {
if ($Left[$i] -ne $Right[$i]) {
return $false
}
}
return $true
}
function Test-BytePatternMatch {
param(
[byte[]]$Bytes,
[object[]]$Pattern
)
if ($Bytes.Length -ne $Pattern.Length) {
return $false
}
for ($i = 0; $i -lt $Bytes.Length; $i++) {
$expected = [string]$Pattern[$i]
if ($expected -eq '??') {
continue
}
if ($Bytes[$i] -ne [Convert]::ToByte($expected, 16)) {
return $false
}
}
return $true
}
function Convert-BytesToStoredHex {
param([byte[]]$Bytes)
return (($Bytes | ForEach-Object { $_.ToString('X2') }) -join '')
}
function Convert-StoredHexToBytes {
param([string]$Hex)
if ([string]::IsNullOrWhiteSpace($Hex)) {
return $null
}
$normalized = $Hex.Replace(' ', '')
if (($normalized.Length % 2) -ne 0) {
throw "Invalid stored hex string length: $Hex"
}
$result = New-Object byte[] ($normalized.Length / 2)
for ($i = 0; $i -lt $result.Length; $i++) {
$result[$i] = [Convert]::ToByte($normalized.Substring($i * 2, 2), 16)
}
return $result
}
2026-03-29 01:14:09 +01:00
function Convert-ToUInt16Value {
param(
[string]$Text,
[string]$Label
)
if ([string]::IsNullOrWhiteSpace($Text)) {
throw "$Label is required."
}
$trimmed = $Text.Trim()
if ($trimmed.StartsWith('0x', [System.StringComparison]::OrdinalIgnoreCase)) {
return [Convert]::ToUInt16($trimmed.Substring(2), 16)
}
return [Convert]::ToUInt16($trimmed, 10)
}
function New-FirstMissionStartBytes {
param(
[int]$Map,
[int]$Egg
)
$bytes = [byte[]](0x6A, 0x01, 0x66, 0x68, 0x00, 0x00, 0x00, 0x00)
Set-U16Le -Bytes $bytes -Offset 4 -Value $Map
Set-U16Le -Bytes $bytes -Offset 6 -Value $Egg
return $bytes
}
function Get-FirstMissionStartConfig {
param([byte[]]$FileBytes)
$site = $sites.FirstMissionStart
$current = Get-ByteSlice -Bytes $FileBytes -Offset $site.Offset -Count $site.Original.Length
if ($current[0] -ne 0x6A -or $current[1] -ne 0x01 -or $current[2] -ne 0x66 -or $current[3] -ne 0x68) {
return @{
State = 'Unknown'
Bytes = $current
}
}
$map = Get-U16Le -Bytes $current -Offset 4
$egg = Get-U16Le -Bytes $current -Offset 6
$state = if ($map -eq $site.DefaultMap -and $egg -eq $site.DefaultEgg) { 'Original' } else { 'Custom' }
return @{
State = $state
Map = $map
Egg = $egg
Bytes = $current
}
}
function Format-FirstMissionStartStatus {
param([hashtable]$Config)
if ($Config.State -eq 'Unknown') {
return 'Unknown'
}
return ('{0} (map {1}, egg 0x{2:X4})' -f $Config.State, $Config.Map, $Config.Egg)
}
function Assert-FirstMissionStartKnown {
param([byte[]]$FileBytes)
$site = $sites.FirstMissionStart
$config = Get-FirstMissionStartConfig -FileBytes $FileBytes
if ($config.State -eq 'Unknown') {
throw (
"{0} at file offset 0x{1:X} does not match the expected startup push pattern.`nCurrent : {2}`nOriginal: {3}`n`nRefusing to modify an unknown executable state." -f
$site.Label,
$site.Offset,
(Format-HexBytes -Bytes $config.Bytes),
(Format-HexBytes -Bytes $site.Original)
)
}
}
function Set-FirstMissionStartConfig {
param(
[int]$Map,
[int]$Egg,
[string]$Label
)
$fileBytes = [System.IO.File]::ReadAllBytes($exePath)
Assert-FirstMissionStartKnown -FileBytes $fileBytes
$targetBytes = New-FirstMissionStartBytes -Map $Map -Egg $Egg
Set-ByteSlice -Bytes $fileBytes -Offset $sites.FirstMissionStart.Offset -Value $targetBytes
[System.IO.File]::WriteAllBytes($exePath, $fileBytes)
$verifyBytes = [System.IO.File]::ReadAllBytes($exePath)
$verified = Get-ByteSlice -Bytes $verifyBytes -Offset $sites.FirstMissionStart.Offset -Count $targetBytes.Length
if (-not (Test-ByteArrayEqual -Left $verified -Right $targetBytes)) {
throw 'Fresh-game startup map/egg verification failed after write.'
}
$config = Get-FirstMissionStartConfig -FileBytes $verifyBytes
Write-Host ''
Write-Host ("Applied: {0}" -f $Label)
Write-Host ("Fresh-game start @ 0x{0:X}: {1}" -f $sites.FirstMissionStart.Offset, (Format-FirstMissionStartStatus -Config $config))
if ($config.State -eq 'Custom') {
Write-Host '- This only redirects the normal fresh-game startup call in Game_Start.'
Write-Host '- The debug -warp mission table and the rest of the cheat-menu patch logic are unchanged.'
}
else {
Write-Host '- The normal fresh-game startup selector is back at retail defaults: map 1, egg 0x001E.'
}
Write-Host ''
}
function Read-FirstMissionRedirectInput {
param([hashtable]$CurrentConfig)
$mapText = Read-Host 'Enter fresh-game startup map number (decimal or 0x-prefixed hex)'
$map = Convert-ToUInt16Value -Text $mapText -Label 'First mission map'
$defaultEgg = if ($CurrentConfig.State -eq 'Unknown') {
[int]$sites.FirstMissionStart.DefaultEgg
}
else {
[int]$CurrentConfig.Egg
}
$eggPrompt = 'Enter teleport egg id (blank keeps 0x{0:X4})' -f $defaultEgg
$eggText = Read-Host $eggPrompt
$egg = if ([string]::IsNullOrWhiteSpace($eggText)) {
$defaultEgg
}
else {
Convert-ToUInt16Value -Text $eggText -Label 'First mission egg'
}
return @{
Map = $map
Egg = $egg
}
}
function Get-HookFixupInfo {
param(
[byte[]]$FileBytes,
[hashtable]$Site
)
$neHeaderOffset = [int](Get-U32Le -Bytes $FileBytes -Offset 0x3C)
$neSignature = Get-ByteSlice -Bytes $FileBytes -Offset $neHeaderOffset -Count 2
if ($neSignature[0] -ne 0x4E -or $neSignature[1] -ne 0x45) {
throw 'Executable does not contain a valid NE header at e_lfanew.'
}
$numSegments = [int](Get-U16Le -Bytes $FileBytes -Offset ($neHeaderOffset + 0x1C))
$segmentTableOffset = [int](Get-U16Le -Bytes $FileBytes -Offset ($neHeaderOffset + 0x22)) + $neHeaderOffset
$alignmentShift = [int](Get-U16Le -Bytes $FileBytes -Offset ($neHeaderOffset + 0x32))
$operandFileOffset = [int]$Site.Fixup.OperandOffset
$segmentInfoByIndex = @{}
$sourceSegment = $null
for ($index = 1; $index -le $numSegments; $index++) {
$segmentEntryOffset = $segmentTableOffset + (($index - 1) * 8)
$sectorOffset = [int](Get-U16Le -Bytes $FileBytes -Offset $segmentEntryOffset)
if ($sectorOffset -eq 0) {
continue
}
$segmentLength = [int](Get-U16Le -Bytes $FileBytes -Offset ($segmentEntryOffset + 2))
if ($segmentLength -eq 0) {
$segmentLength = 0x10000
}
$segmentFlags = [int](Get-U16Le -Bytes $FileBytes -Offset ($segmentEntryOffset + 4))
$segmentFileOffset = [int]($sectorOffset -shl $alignmentShift)
$segmentInfo = @{
SegmentIndex = $index
SegmentEntryOffset = $segmentEntryOffset
SegmentFileOffset = $segmentFileOffset
SegmentLength = $segmentLength
SegmentFlags = $segmentFlags
HasReloc = (($segmentFlags -band 0x0100) -ne 0)
}
$segmentInfoByIndex[$index] = $segmentInfo
if ($segmentFileOffset -le $operandFileOffset -and $operandFileOffset -lt ($segmentFileOffset + $segmentLength)) {
$sourceSegment = $segmentInfo
break
}
}
if ($null -eq $sourceSegment) {
$fallbackSegmentIndex = [int]$Site.Fixup.RetailSourceSegmentIndex
$fallbackChainOffset = [int]$Site.Fixup.RetailChainOffset
if ($segmentInfoByIndex.ContainsKey($fallbackSegmentIndex)) {
$fallbackSegment = $segmentInfoByIndex[$fallbackSegmentIndex]
if (($fallbackSegment.SegmentFileOffset + $fallbackChainOffset) -eq $operandFileOffset) {
$sourceSegment = $fallbackSegment
}
}
}
if ($null -eq $sourceSegment) {
throw ("Could not locate the hook operand file offset 0x{0:X} in any NE code segment." -f $operandFileOffset)
}
if (-not $sourceSegment.HasReloc) {
throw 'Hook source segment does not advertise a relocation table.'
}
$segmentIndex = [int]$sourceSegment.SegmentIndex
$segmentFileOffset = [int]$sourceSegment.SegmentFileOffset
$segmentLength = [int]$sourceSegment.SegmentLength
$relocTableOffset = $segmentFileOffset + $segmentLength
$relocCount = [int](Get-U16Le -Bytes $FileBytes -Offset $relocTableOffset)
$entryOffset = $relocTableOffset + 2
$chainOffset = $operandFileOffset - $segmentFileOffset
for ($index = 0; $index -lt $relocCount; $index++) {
$addrType = $FileBytes[$entryOffset]
$relType = $FileBytes[$entryOffset + 1]
$entryChainOffset = Get-U16Le -Bytes $FileBytes -Offset ($entryOffset + 2)
if ($entryChainOffset -eq $chainOffset) {
return @{
SegmentIndex = $segmentIndex
SegmentFileOffset = $segmentFileOffset
RelocTableOffset = $relocTableOffset
EntryOffset = $entryOffset
EntryIndex = $index
AddrType = $addrType
RelType = $relType
ChainOffset = $entryChainOffset
TargetSeg = $FileBytes[$entryOffset + 4]
Reserved = $FileBytes[$entryOffset + 5]
TargetOffset = Get-U16Le -Bytes $FileBytes -Offset ($entryOffset + 6)
}
}
if ($addrType -eq 3 -and (($relType -band 0x03) -eq 0)) {
$visited = New-Object 'System.Collections.Generic.HashSet[int]'
$currentChainOffset = $entryChainOffset
while ($currentChainOffset -ne 0xFFFF -and $currentChainOffset -lt $segmentLength) {
if (-not $visited.Add($currentChainOffset)) {
break
}
if ($currentChainOffset -eq $chainOffset) {
return @{
SegmentIndex = $segmentIndex
SegmentFileOffset = $segmentFileOffset
RelocTableOffset = $relocTableOffset
EntryOffset = $entryOffset
EntryIndex = $index
AddrType = $addrType
RelType = $relType
ChainOffset = $entryChainOffset
TargetSeg = $FileBytes[$entryOffset + 4]
Reserved = $FileBytes[$entryOffset + 5]
TargetOffset = Get-U16Le -Bytes $FileBytes -Offset ($entryOffset + 6)
}
}
$currentChainOffset = Get-U16Le -Bytes $FileBytes -Offset ($segmentFileOffset + $currentChainOffset)
}
}
$entryOffset += 8
}
$fallbackEntryOffset = [int]$Site.Fixup.RetailEntryOffset
$fallbackChainOffset = [int]$Site.Fixup.RetailChainOffset
if ($fallbackEntryOffset -ge 0 -and ($fallbackEntryOffset + 7) -lt $FileBytes.Length) {
$addrType = $FileBytes[$fallbackEntryOffset]
$relType = $FileBytes[$fallbackEntryOffset + 1]
$entryChainOffset = [int](Get-U16Le -Bytes $FileBytes -Offset ($fallbackEntryOffset + 2))
if (
$sourceSegment.SegmentIndex -eq [int]$Site.Fixup.RetailSourceSegmentIndex -and
$chainOffset -eq $fallbackChainOffset -and
$addrType -eq 3 -and
(($relType -band 0x03) -eq 0) -and
$entryChainOffset -eq $fallbackChainOffset
) {
return @{
SegmentIndex = $segmentIndex
SegmentFileOffset = $segmentFileOffset
RelocTableOffset = $relocTableOffset
EntryOffset = $fallbackEntryOffset
EntryIndex = -1
AddrType = $addrType
RelType = $relType
ChainOffset = $entryChainOffset
TargetSeg = $FileBytes[$fallbackEntryOffset + 4]
Reserved = $FileBytes[$fallbackEntryOffset + 5]
TargetOffset = Get-U16Le -Bytes $FileBytes -Offset ($fallbackEntryOffset + 6)
}
}
}
throw ("Could not find hook relocation entry for segment {0} chain offset 0x{1:X}." -f $segmentIndex, $chainOffset)
}
function Get-StateData {
if (-not (Test-Path -LiteralPath $statePath)) {
return @{}
}
$raw = Get-Content -LiteralPath $statePath -Raw
if ([string]::IsNullOrWhiteSpace($raw)) {
return @{}
}
$parsed = ConvertFrom-Json -InputObject $raw -AsHashtable
if ($null -eq $parsed) {
return @{}
}
return $parsed
}
function Save-StateData {
param([hashtable]$StateData)
$json = ConvertTo-Json -InputObject $StateData -Depth 4
Set-Content -LiteralPath $statePath -Value $json -Encoding ASCII
}
function Get-SavedOriginalBytes {
param(
[hashtable]$StateData,
[string]$SiteKey,
[int]$SiteOffset
)
if (-not $StateData.ContainsKey($SiteKey)) {
return $null
}
$siteState = $StateData[$SiteKey]
if ($siteState -isnot [hashtable]) {
return $null
}
if (-not $siteState.ContainsKey('OriginalBytes')) {
return $null
}
if ($siteState.ContainsKey('Offset') -and ([int]$siteState.Offset -ne $SiteOffset)) {
return $null
}
return Convert-StoredHexToBytes -Hex ([string]$siteState.OriginalBytes)
}
function Save-OriginalBytesIfMissing {
param(
[hashtable]$StateData,
[string]$SiteKey,
[int]$SiteOffset,
[byte[]]$Bytes
)
if ($StateData.ContainsKey($SiteKey) -and ($StateData[$SiteKey] -is [hashtable])) {
$existing = $StateData[$SiteKey]
if ($existing.ContainsKey('OriginalBytes') -and $existing.ContainsKey('Offset') -and ([int]$existing.Offset -eq $SiteOffset)) {
return $false
}
}
$StateData[$SiteKey] = @{
Offset = $SiteOffset
OriginalBytes = Convert-BytesToStoredHex -Bytes $Bytes
}
return $true
}
function Get-SiteState {
param(
[byte[]]$FileBytes,
[hashtable]$Site
)
$current = Get-ByteSlice -Bytes $FileBytes -Offset $Site.Offset -Count $Site.Original.Length
if ($Site.ContainsKey('Fixup')) {
$fixupInfo = Get-HookFixupInfo -FileBytes $FileBytes -Site $Site
$isFixupOriginal = ($fixupInfo.TargetSeg -eq $Site.Fixup.OriginalTargetSeg) -and ($fixupInfo.TargetOffset -eq $Site.Fixup.OriginalTargetOffset)
$isFixupPatched = ($fixupInfo.TargetSeg -eq $Site.Fixup.PatchedTargetSeg) -and ($fixupInfo.TargetOffset -eq $Site.Fixup.PatchedTargetOffset)
if ($Site.ContainsKey('LegacyBadPatched') -and (Test-ByteArrayEqual -Left $current -Right $Site.LegacyBadPatched) -and $isFixupOriginal) {
return 'LegacyBadPatch'
}
if ((Test-ByteArrayEqual -Left $current -Right $Site.Patched) -and $isFixupPatched) {
return 'Patched'
}
if ((Test-ByteArrayEqual -Left $current -Right $Site.Original) -and $isFixupOriginal) {
return 'Original'
}
foreach ($pattern in $Site.OriginalPatterns) {
if ((Test-BytePatternMatch -Bytes $current -Pattern $pattern) -and $isFixupOriginal) {
return 'Original'
}
}
$legacyCandidates = @()
if ($Site.ContainsKey('LegacyPatched')) {
$legacyCandidates += ,([byte[]]$Site.LegacyPatched)
}
if ($Site.ContainsKey('LegacyPatchedVariants')) {
$legacyCandidates += $Site.LegacyPatchedVariants
}
foreach ($legacyCandidate in $legacyCandidates) {
if (Test-ByteArrayEqual -Left $current -Right $legacyCandidate) {
$legacyFixupState = if ($Site.ContainsKey('LegacyPatchedFixupState')) { [string]$Site.LegacyPatchedFixupState } else { 'Patched' }
if (
($legacyFixupState -eq 'Patched' -and $isFixupPatched) -or
($legacyFixupState -eq 'Original' -and $isFixupOriginal) -or
($legacyFixupState -eq 'Any' -and ($isFixupPatched -or $isFixupOriginal))
) {
return 'LegacyPatched'
}
}
}
return 'Unknown'
}
if (Test-ByteArrayEqual -Left $current -Right $Site.Patched) {
return 'Patched'
}
if (Test-ByteArrayEqual -Left $current -Right $Site.Original) {
return 'Original'
}
foreach ($pattern in $Site.OriginalPatterns) {
if (Test-BytePatternMatch -Bytes $current -Pattern $pattern) {
return 'Original'
}
}
return 'Unknown'
}
function Assert-SiteStateKnown {
param(
[byte[]]$FileBytes,
[hashtable]$Site
)
$state = Get-SiteState -FileBytes $FileBytes -Site $Site
if ($state -eq 'Unknown') {
$current = Get-ByteSlice -Bytes $FileBytes -Offset $Site.Offset -Count $Site.Original.Length
throw (
"{0} at file offset 0x{1:X} does not match either expected byte sequence.`nCurrent : {2}`nOriginal: {3}`nPatched : {4}`n`nRefusing to modify an unknown executable state." -f
$Site.Label,
$Site.Offset,
(Format-HexBytes -Bytes $current),
(Format-HexBytes -Bytes $Site.Original),
(Format-HexBytes -Bytes $Site.Patched)
)
}
}
function Set-ByteSlice {
param(
[byte[]]$Bytes,
[int]$Offset,
[byte[]]$Value
)
[Array]::Copy($Value, 0, $Bytes, $Offset, $Value.Length)
}
function Show-Status {
param([byte[]]$FileBytes)
$selectedProfile = Get-ConfiguredCandidateProfile
$appliedProfileKey = Get-AppliedCandidateProfileKey -FileBytes $FileBytes
$appliedProfile = if ($null -ne $appliedProfileKey) { $candidateProfiles[$appliedProfileKey] } else { $null }
$ctrlQState = Get-SiteState -FileBytes $FileBytes -Site $sites.CtrlQDebuggerInit
$interpreterCallState = Get-SiteState -FileBytes $FileBytes -Site $sites.InterpreterBreakCall
$privateDispatchState = Get-SiteState -FileBytes $FileBytes -Site $sites.DebuggerCallback
$callbackGuardState = Get-SiteState -FileBytes $FileBytes -Site $sites.CallbackGuardCode
$callbackTargetState = Get-SiteState -FileBytes $FileBytes -Site $sites.CallbackTargetSlot
$hookState = Get-SiteState -FileBytes $FileBytes -Site $sites.Hook
$wrapperState = Get-SiteState -FileBytes $FileBytes -Site $sites.Wrapper
$deferredHookState = Get-SiteState -FileBytes $FileBytes -Site $sites.LegacyDeferredHook
$deferredWrapperState = Get-SiteState -FileBytes $FileBytes -Site $sites.LegacyDeferredWrapper
2026-03-29 01:14:09 +01:00
$firstMissionConfig = Get-FirstMissionStartConfig -FileBytes $FileBytes
Write-Host ''
Write-Host 'CRUSADER.EXE patch status'
Write-Host '------------------------'
Write-Host ("EXE: {0}" -f $exePath)
Write-Host ("Selected candidate : {0} ({1})" -f $selectedProfile.Label, $selectedProfile.Summary)
if ($null -ne $appliedProfile) {
Write-Host ("Applied candidate : {0} ({1})" -f $appliedProfile.Label, $appliedProfile.Summary)
}
elseif ($ctrlQState -eq 'Original') {
Write-Host 'Applied candidate : Retail/original'
}
else {
Write-Host 'Applied candidate : Unknown'
}
Write-Host ("0x410 init body @ 0x{0:X}: {1}" -f $sites.CtrlQDebuggerInit.Offset, $ctrlQState)
Write-Host ("Interpreter call @ 0x{0:X}: {1}" -f $sites.InterpreterBreakCall.Offset, $interpreterCallState)
Write-Host ("Private UI call @ 0x{0:X}: {1}" -f $sites.DebuggerCallback.Offset, $privateDispatchState)
Write-Host ("Legacy break hook @ 0x{0:X}: {1}" -f $sites.CallbackGuardCode.Offset, $callbackGuardState)
Write-Host ("Legacy target slot @ 0x{0:X}: {1}" -f $sites.CallbackTargetSlot.Offset, $callbackTargetState)
Write-Host ("Current-unit args @ 0x{0:X}: {1}" -f $sites.Wrapper.Offset, $wrapperState)
Write-Host ("Modal wrapper args @ 0x{0:X}: {1}" -f $sites.LegacyDeferredWrapper.Offset, $deferredWrapperState)
Write-Host ("Deferred hook cleanup @ 0x{0:X}: {1}" -f $sites.LegacyDeferredHook.Offset, $deferredHookState)
Write-Host ("Direct hook cleanup @ 0x{0:X}: {1}" -f $sites.Hook.Offset, $hookState)
2026-03-29 01:14:09 +01:00
Write-Host ("Fresh-game start @ 0x{0:X}: {1}" -f $sites.FirstMissionStart.Offset, (Format-FirstMissionStartStatus -Config $firstMissionConfig))
Write-Host ''
foreach ($profileKey in $candidateProfiles.Keys) {
2026-03-29 01:14:09 +01:00
$candidateProfile = $candidateProfiles[$profileKey]
$marker = if ($appliedProfileKey -eq $profileKey) { 'applied' } else { 'ready' }
2026-03-29 01:14:09 +01:00
Write-Host ("{0}. Apply {1} [{2}] {3}" -f $candidateProfile.MenuKey, $candidateProfile.Label, $marker, $candidateProfile.Summary)
}
Write-Host '3. Restore original bytes'
2026-03-29 01:14:09 +01:00
Write-Host '5. Redirect fresh-game startup map/egg'
Write-Host '6. Restore fresh-game startup default'
Write-Host '4. Exit'
Write-Host ''
}
function Set-DesiredState {
param(
[bool]$CtrlQPatched,
[string]$Label,
[string]$ProfileKey
)
$fileBytes = [System.IO.File]::ReadAllBytes($exePath)
$appliedProfileKey = Get-AppliedCandidateProfileKey -FileBytes $fileBytes
$profileKeyForStateCheck = if ($null -ne $appliedProfileKey) { $appliedProfileKey } elseif ($CtrlQPatched) { $ProfileKey } else { $script:configuredProfileKey }
$profileKeyForWrite = if ($CtrlQPatched) { $ProfileKey } else { $profileKeyForStateCheck }
if ([string]::IsNullOrWhiteSpace($profileKeyForStateCheck)) {
throw 'No candidate profile is available for state validation.'
}
Set-ConfiguredCandidateProfile -ProfileKey $profileKeyForStateCheck
Assert-SiteStateKnown -FileBytes $fileBytes -Site $sites.CtrlQDebuggerInit
Assert-SiteStateKnown -FileBytes $fileBytes -Site $sites.InterpreterBreakCall
Assert-SiteStateKnown -FileBytes $fileBytes -Site $sites.DebuggerCallback
Assert-SiteStateKnown -FileBytes $fileBytes -Site $sites.CallbackGuardCode
Assert-SiteStateKnown -FileBytes $fileBytes -Site $sites.CallbackTargetSlot
Assert-SiteStateKnown -FileBytes $fileBytes -Site $sites.PrivateBreakpointMethod0
Assert-SiteStateKnown -FileBytes $fileBytes -Site $sites.PrivateBreakpointMethod1
Assert-SiteStateKnown -FileBytes $fileBytes -Site $sites.LegacyBreakpointCallback
Assert-SiteStateKnown -FileBytes $fileBytes -Site $sites.Hook
Assert-SiteStateKnown -FileBytes $fileBytes -Site $sites.Wrapper
Assert-SiteStateKnown -FileBytes $fileBytes -Site $sites.LegacyDeferredHook
Assert-SiteStateKnown -FileBytes $fileBytes -Site $sites.LegacyDeferredWrapper
Set-ConfiguredCandidateProfile -ProfileKey $profileKeyForWrite
$activeProfile = Get-ConfiguredCandidateProfile
$ctrlQFixupInfo = Get-HookFixupInfo -FileBytes $fileBytes -Site $sites.CtrlQDebuggerInit
$interpreterBreakCallFixupInfo = Get-HookFixupInfo -FileBytes $fileBytes -Site $sites.InterpreterBreakCall
$debuggerCallbackFixupInfo = Get-HookFixupInfo -FileBytes $fileBytes -Site $sites.DebuggerCallback
$callbackTargetFixupInfo = Get-HookFixupInfo -FileBytes $fileBytes -Site $sites.CallbackTargetSlot
$privateMethod0FixupInfo = Get-HookFixupInfo -FileBytes $fileBytes -Site $sites.PrivateBreakpointMethod0
$privateMethod1FixupInfo = Get-HookFixupInfo -FileBytes $fileBytes -Site $sites.PrivateBreakpointMethod1
$legacyCallbackFixupInfo = Get-HookFixupInfo -FileBytes $fileBytes -Site $sites.LegacyBreakpointCallback
$hookFixupInfo = Get-HookFixupInfo -FileBytes $fileBytes -Site $sites.Hook
$legacyDeferredHookFixupInfo = Get-HookFixupInfo -FileBytes $fileBytes -Site $sites.LegacyDeferredHook
$ctrlQBytes = if ($CtrlQPatched) { $sites.CtrlQDebuggerInit.Patched } else { $sites.CtrlQDebuggerInit.Original }
$ctrlQTargetSeg = if ($CtrlQPatched) { [int]$sites.CtrlQDebuggerInit.Fixup.PatchedTargetSeg } else { [int]$sites.CtrlQDebuggerInit.Fixup.OriginalTargetSeg }
$ctrlQTargetOffset = if ($CtrlQPatched) { [int]$sites.CtrlQDebuggerInit.Fixup.PatchedTargetOffset } else { [int]$sites.CtrlQDebuggerInit.Fixup.OriginalTargetOffset }
$interpreterBreakCallBytes = $sites.InterpreterBreakCall.Original
$interpreterBreakCallSeg = [int]$sites.InterpreterBreakCall.Fixup.OriginalTargetSeg
$interpreterBreakCallOffset = [int]$sites.InterpreterBreakCall.Fixup.OriginalTargetOffset
if ($CtrlQPatched) {
$interpreterBreakCallBytes = $sites.InterpreterBreakCall.Patched
$interpreterBreakCallSeg = [int]$sites.InterpreterBreakCall.Fixup.PatchedTargetSeg
$interpreterBreakCallOffset = [int]$sites.InterpreterBreakCall.Fixup.PatchedTargetOffset
}
$debuggerCallbackBytes = $sites.DebuggerCallback.Original
$debuggerCallbackSeg = [int]$sites.DebuggerCallback.Fixup.OriginalTargetSeg
$debuggerCallbackOffset = [int]$sites.DebuggerCallback.Fixup.OriginalTargetOffset
if ($CtrlQPatched) {
$debuggerCallbackBytes = $sites.DebuggerCallback.Patched
$debuggerCallbackSeg = [int]$sites.DebuggerCallback.Fixup.PatchedTargetSeg
$debuggerCallbackOffset = [int]$sites.DebuggerCallback.Fixup.PatchedTargetOffset
}
$callbackGuardBytes = $sites.CallbackGuardCode.Original
$callbackTargetBytes = $sites.CallbackTargetSlot.Original
$callbackTargetSeg = [int]$sites.CallbackTargetSlot.Fixup.OriginalTargetSeg
$callbackTargetOffset = [int]$sites.CallbackTargetSlot.Fixup.OriginalTargetOffset
$privateMethod0Bytes = $sites.PrivateBreakpointMethod0.Original
$privateMethod0TargetSeg = [int]$sites.PrivateBreakpointMethod0.Fixup.OriginalTargetSeg
$privateMethod0TargetOffset = [int]$sites.PrivateBreakpointMethod0.Fixup.OriginalTargetOffset
$privateMethod1Bytes = $sites.PrivateBreakpointMethod1.Original
$privateMethod1TargetSeg = [int]$sites.PrivateBreakpointMethod1.Fixup.OriginalTargetSeg
$privateMethod1TargetOffset = [int]$sites.PrivateBreakpointMethod1.Fixup.OriginalTargetOffset
$legacyCallbackBytes = $sites.LegacyBreakpointCallback.Original
$legacyCallbackTargetSeg = [int]$sites.LegacyBreakpointCallback.Fixup.OriginalTargetSeg
$legacyCallbackTargetOffset = [int]$sites.LegacyBreakpointCallback.Fixup.OriginalTargetOffset
$hookBytes = $sites.Hook.Original
$hookTargetSeg = [int]$sites.Hook.Fixup.OriginalTargetSeg
$hookTargetOffset = [int]$sites.Hook.Fixup.OriginalTargetOffset
$hookReserved = 0
$wrapperBytes = if ($CtrlQPatched -and $activeProfile.PatchCurrentUnitWrapper) { $sites.Wrapper.Patched } else { $sites.Wrapper.Original }
$legacyDeferredHookBytes = $sites.LegacyDeferredHook.Original
$legacyDeferredHookTargetSeg = [int]$sites.LegacyDeferredHook.Fixup.OriginalTargetSeg
$legacyDeferredHookTargetOffset = [int]$sites.LegacyDeferredHook.Fixup.OriginalTargetOffset
$legacyDeferredHookReserved = 0
$legacyDeferredWrapperBytes = if ($CtrlQPatched -and $activeProfile.PatchModalWrapper) { $sites.LegacyDeferredWrapper.Patched } else { $sites.LegacyDeferredWrapper.Original }
Set-ByteSlice -Bytes $fileBytes -Offset $sites.CtrlQDebuggerInit.Offset -Value $ctrlQBytes
$fileBytes[$ctrlQFixupInfo.EntryOffset + 4] = [byte]$ctrlQTargetSeg
$fileBytes[$ctrlQFixupInfo.EntryOffset + 5] = 0
Set-U16Le -Bytes $fileBytes -Offset ($ctrlQFixupInfo.EntryOffset + 6) -Value $ctrlQTargetOffset
Set-ByteSlice -Bytes $fileBytes -Offset $sites.InterpreterBreakCall.Offset -Value $interpreterBreakCallBytes
$fileBytes[$interpreterBreakCallFixupInfo.EntryOffset + 4] = [byte]$interpreterBreakCallSeg
$fileBytes[$interpreterBreakCallFixupInfo.EntryOffset + 5] = 0
Set-U16Le -Bytes $fileBytes -Offset ($interpreterBreakCallFixupInfo.EntryOffset + 6) -Value $interpreterBreakCallOffset
Set-ByteSlice -Bytes $fileBytes -Offset $sites.DebuggerCallback.Offset -Value $debuggerCallbackBytes
$fileBytes[$debuggerCallbackFixupInfo.EntryOffset + 4] = [byte]$debuggerCallbackSeg
$fileBytes[$debuggerCallbackFixupInfo.EntryOffset + 5] = 0
Set-U16Le -Bytes $fileBytes -Offset ($debuggerCallbackFixupInfo.EntryOffset + 6) -Value $debuggerCallbackOffset
Set-ByteSlice -Bytes $fileBytes -Offset $sites.CallbackGuardCode.Offset -Value $callbackGuardBytes
Set-ByteSlice -Bytes $fileBytes -Offset $sites.CallbackTargetSlot.Offset -Value $callbackTargetBytes
$fileBytes[$callbackTargetFixupInfo.EntryOffset + 4] = [byte]$callbackTargetSeg
$fileBytes[$callbackTargetFixupInfo.EntryOffset + 5] = 0
Set-U16Le -Bytes $fileBytes -Offset ($callbackTargetFixupInfo.EntryOffset + 6) -Value $callbackTargetOffset
Set-ByteSlice -Bytes $fileBytes -Offset $sites.PrivateBreakpointMethod0.Offset -Value $privateMethod0Bytes
$fileBytes[$privateMethod0FixupInfo.EntryOffset + 4] = [byte]$privateMethod0TargetSeg
$fileBytes[$privateMethod0FixupInfo.EntryOffset + 5] = 0
Set-U16Le -Bytes $fileBytes -Offset ($privateMethod0FixupInfo.EntryOffset + 6) -Value $privateMethod0TargetOffset
Set-ByteSlice -Bytes $fileBytes -Offset $sites.PrivateBreakpointMethod1.Offset -Value $privateMethod1Bytes
$fileBytes[$privateMethod1FixupInfo.EntryOffset + 4] = [byte]$privateMethod1TargetSeg
$fileBytes[$privateMethod1FixupInfo.EntryOffset + 5] = 0
Set-U16Le -Bytes $fileBytes -Offset ($privateMethod1FixupInfo.EntryOffset + 6) -Value $privateMethod1TargetOffset
Set-ByteSlice -Bytes $fileBytes -Offset $sites.LegacyBreakpointCallback.Offset -Value $legacyCallbackBytes
$fileBytes[$legacyCallbackFixupInfo.EntryOffset + 4] = [byte]$legacyCallbackTargetSeg
$fileBytes[$legacyCallbackFixupInfo.EntryOffset + 5] = 0
Set-U16Le -Bytes $fileBytes -Offset ($legacyCallbackFixupInfo.EntryOffset + 6) -Value $legacyCallbackTargetOffset
Set-ByteSlice -Bytes $fileBytes -Offset $sites.Hook.Offset -Value $hookBytes
$fileBytes[$hookFixupInfo.EntryOffset + 4] = [byte]$hookTargetSeg
$fileBytes[$hookFixupInfo.EntryOffset + 5] = [byte]$hookReserved
Set-U16Le -Bytes $fileBytes -Offset ($hookFixupInfo.EntryOffset + 6) -Value $hookTargetOffset
Set-ByteSlice -Bytes $fileBytes -Offset $sites.Wrapper.Offset -Value $wrapperBytes
Set-ByteSlice -Bytes $fileBytes -Offset $sites.LegacyDeferredHook.Offset -Value $legacyDeferredHookBytes
$fileBytes[$legacyDeferredHookFixupInfo.EntryOffset + 4] = [byte]$legacyDeferredHookTargetSeg
$fileBytes[$legacyDeferredHookFixupInfo.EntryOffset + 5] = [byte]$legacyDeferredHookReserved
Set-U16Le -Bytes $fileBytes -Offset ($legacyDeferredHookFixupInfo.EntryOffset + 6) -Value $legacyDeferredHookTargetOffset
Set-ByteSlice -Bytes $fileBytes -Offset $sites.LegacyDeferredWrapper.Offset -Value $legacyDeferredWrapperBytes
[System.IO.File]::WriteAllBytes($exePath, $fileBytes)
$verifyBytes = [System.IO.File]::ReadAllBytes($exePath)
$ctrlQState = Get-SiteState -FileBytes $verifyBytes -Site $sites.CtrlQDebuggerInit
$interpreterCallState = Get-SiteState -FileBytes $verifyBytes -Site $sites.InterpreterBreakCall
$privateDispatchState = Get-SiteState -FileBytes $verifyBytes -Site $sites.DebuggerCallback
$callbackGuardState = Get-SiteState -FileBytes $verifyBytes -Site $sites.CallbackGuardCode
$callbackTargetState = Get-SiteState -FileBytes $verifyBytes -Site $sites.CallbackTargetSlot
$hookState = Get-SiteState -FileBytes $verifyBytes -Site $sites.Hook
$wrapperState = Get-SiteState -FileBytes $verifyBytes -Site $sites.Wrapper
$deferredHookState = Get-SiteState -FileBytes $verifyBytes -Site $sites.LegacyDeferredHook
$deferredWrapperState = Get-SiteState -FileBytes $verifyBytes -Site $sites.LegacyDeferredWrapper
$verifiedCtrlQFixupInfo = Get-HookFixupInfo -FileBytes $verifyBytes -Site $sites.CtrlQDebuggerInit
$verifiedInterpreterBreakCallFixupInfo = Get-HookFixupInfo -FileBytes $verifyBytes -Site $sites.InterpreterBreakCall
$verifiedDebuggerCallbackFixupInfo = Get-HookFixupInfo -FileBytes $verifyBytes -Site $sites.DebuggerCallback
$verifiedCallbackTargetFixupInfo = Get-HookFixupInfo -FileBytes $verifyBytes -Site $sites.CallbackTargetSlot
$verifiedPrivateMethod0FixupInfo = Get-HookFixupInfo -FileBytes $verifyBytes -Site $sites.PrivateBreakpointMethod0
$verifiedPrivateMethod1FixupInfo = Get-HookFixupInfo -FileBytes $verifyBytes -Site $sites.PrivateBreakpointMethod1
$verifiedLegacyCallbackFixupInfo = Get-HookFixupInfo -FileBytes $verifyBytes -Site $sites.LegacyBreakpointCallback
$verifiedHookFixupInfo = Get-HookFixupInfo -FileBytes $verifyBytes -Site $sites.Hook
$verifiedLegacyDeferredHookFixupInfo = Get-HookFixupInfo -FileBytes $verifyBytes -Site $sites.LegacyDeferredHook
$verifiedCtrlQBytes = Get-ByteSlice -Bytes $verifyBytes -Offset $sites.CtrlQDebuggerInit.Offset -Count $ctrlQBytes.Length
$verifiedInterpreterBreakCallBytes = Get-ByteSlice -Bytes $verifyBytes -Offset $sites.InterpreterBreakCall.Offset -Count $interpreterBreakCallBytes.Length
$verifiedDebuggerCallbackBytes = Get-ByteSlice -Bytes $verifyBytes -Offset $sites.DebuggerCallback.Offset -Count $debuggerCallbackBytes.Length
$verifiedCallbackGuardBytes = Get-ByteSlice -Bytes $verifyBytes -Offset $sites.CallbackGuardCode.Offset -Count $callbackGuardBytes.Length
$verifiedCallbackTargetBytes = Get-ByteSlice -Bytes $verifyBytes -Offset $sites.CallbackTargetSlot.Offset -Count $callbackTargetBytes.Length
$verifiedPrivateMethod0Bytes = Get-ByteSlice -Bytes $verifyBytes -Offset $sites.PrivateBreakpointMethod0.Offset -Count $privateMethod0Bytes.Length
$verifiedPrivateMethod1Bytes = Get-ByteSlice -Bytes $verifyBytes -Offset $sites.PrivateBreakpointMethod1.Offset -Count $privateMethod1Bytes.Length
$verifiedLegacyCallbackBytes = Get-ByteSlice -Bytes $verifyBytes -Offset $sites.LegacyBreakpointCallback.Offset -Count $legacyCallbackBytes.Length
$verifiedHookBytes = Get-ByteSlice -Bytes $verifyBytes -Offset $sites.Hook.Offset -Count $hookBytes.Length
$verifiedWrapperBytes = Get-ByteSlice -Bytes $verifyBytes -Offset $sites.Wrapper.Offset -Count $wrapperBytes.Length
$verifiedLegacyDeferredHookBytes = Get-ByteSlice -Bytes $verifyBytes -Offset $sites.LegacyDeferredHook.Offset -Count $legacyDeferredHookBytes.Length
$verifiedLegacyDeferredWrapperBytes = Get-ByteSlice -Bytes $verifyBytes -Offset $sites.LegacyDeferredWrapper.Offset -Count $legacyDeferredWrapperBytes.Length
if (-not (Test-ByteArrayEqual -Left $verifiedCtrlQBytes -Right $ctrlQBytes)) {
throw 'Ctrl+Q debugger-init body verification failed after write.'
}
if ($verifiedCtrlQFixupInfo.TargetSeg -ne $ctrlQTargetSeg -or $verifiedCtrlQFixupInfo.TargetOffset -ne $ctrlQTargetOffset) {
throw 'Ctrl+Q debugger-init relocation verification failed after write.'
}
if (-not (Test-ByteArrayEqual -Left $verifiedInterpreterBreakCallBytes -Right $interpreterBreakCallBytes)) {
throw 'Interpreter debugger callsite verification failed after write.'
}
if ($verifiedInterpreterBreakCallFixupInfo.TargetSeg -ne $interpreterBreakCallSeg -or $verifiedInterpreterBreakCallFixupInfo.TargetOffset -ne $interpreterBreakCallOffset) {
throw 'Interpreter debugger callsite relocation verification failed after write.'
}
if (-not (Test-ByteArrayEqual -Left $verifiedDebuggerCallbackBytes -Right $debuggerCallbackBytes)) {
throw 'Private debugger UI call verification failed after write.'
}
if ($verifiedDebuggerCallbackFixupInfo.TargetSeg -ne $debuggerCallbackSeg -or $verifiedDebuggerCallbackFixupInfo.TargetOffset -ne $debuggerCallbackOffset) {
throw 'Private debugger UI call relocation verification failed after write.'
}
if (-not (Test-ByteArrayEqual -Left $verifiedCallbackGuardBytes -Right $callbackGuardBytes)) {
throw 'Break-next dispatch patch verification failed after write.'
}
if (-not (Test-ByteArrayEqual -Left $verifiedCallbackTargetBytes -Right $callbackTargetBytes)) {
throw 'Guarded callback target slot bytes verification failed after write.'
}
if ($verifiedCallbackTargetFixupInfo.TargetSeg -ne $callbackTargetSeg -or $verifiedCallbackTargetFixupInfo.TargetOffset -ne $callbackTargetOffset) {
throw 'Guarded callback target relocation verification failed after write.'
}
if (-not (Test-ByteArrayEqual -Left $verifiedPrivateMethod0Bytes -Right $privateMethod0Bytes)) {
throw 'Private vtable method-0 bytes verification failed after write.'
}
if ($verifiedPrivateMethod0FixupInfo.TargetSeg -ne $privateMethod0TargetSeg -or $verifiedPrivateMethod0FixupInfo.TargetOffset -ne $privateMethod0TargetOffset) {
throw 'Private vtable method-0 relocation verification failed after write.'
}
if (-not (Test-ByteArrayEqual -Left $verifiedPrivateMethod1Bytes -Right $privateMethod1Bytes)) {
throw 'Private vtable method-1 bytes verification failed after write.'
}
if ($verifiedPrivateMethod1FixupInfo.TargetSeg -ne $privateMethod1TargetSeg -or $verifiedPrivateMethod1FixupInfo.TargetOffset -ne $privateMethod1TargetOffset) {
throw 'Private vtable method-1 relocation verification failed after write.'
}
if (-not (Test-ByteArrayEqual -Left $verifiedLegacyCallbackBytes -Right $legacyCallbackBytes)) {
throw 'Legacy callback cleanup bytes verification failed after write.'
}
if ($verifiedLegacyCallbackFixupInfo.TargetSeg -ne $legacyCallbackTargetSeg -or $verifiedLegacyCallbackFixupInfo.TargetOffset -ne $legacyCallbackTargetOffset) {
throw 'Legacy callback cleanup relocation verification failed after write.'
}
if (-not (Test-ByteArrayEqual -Left $verifiedHookBytes -Right $hookBytes)) {
throw 'Hook-site verification failed after write.'
}
if ($verifiedHookFixupInfo.TargetSeg -ne $hookTargetSeg -or $verifiedHookFixupInfo.TargetOffset -ne $hookTargetOffset) {
throw 'Hook relocation verification failed after write.'
}
if (-not (Test-ByteArrayEqual -Left $verifiedWrapperBytes -Right $wrapperBytes)) {
throw 'Wrapper-site verification failed after write.'
}
if (-not (Test-ByteArrayEqual -Left $verifiedLegacyDeferredHookBytes -Right $legacyDeferredHookBytes)) {
throw 'Rejected deferred-event hook cleanup verification failed after write.'
}
if ($verifiedLegacyDeferredHookFixupInfo.TargetSeg -ne $legacyDeferredHookTargetSeg -or $verifiedLegacyDeferredHookFixupInfo.TargetOffset -ne $legacyDeferredHookTargetOffset) {
throw 'Rejected deferred-event relocation cleanup verification failed after write.'
}
if (-not (Test-ByteArrayEqual -Left $verifiedLegacyDeferredWrapperBytes -Right $legacyDeferredWrapperBytes)) {
throw 'Rejected modal-wrapper cleanup verification failed after write.'
}
Write-Host ''
Write-Host ("Applied: {0}" -f $Label)
Write-Host ("0x410 init body @ 0x{0:X}: {1}" -f $sites.CtrlQDebuggerInit.Offset, $ctrlQState)
Write-Host ("Interpreter call @ 0x{0:X}: {1}" -f $sites.InterpreterBreakCall.Offset, $interpreterCallState)
Write-Host ("Private UI call @ 0x{0:X}: {1}" -f $sites.DebuggerCallback.Offset, $privateDispatchState)
Write-Host ("Legacy break hook @ 0x{0:X}: {1}" -f $sites.CallbackGuardCode.Offset, $callbackGuardState)
Write-Host ("Legacy target slot @ 0x{0:X}: {1}" -f $sites.CallbackTargetSlot.Offset, $callbackTargetState)
Write-Host ("Current-unit args @ 0x{0:X}: {1}" -f $sites.Wrapper.Offset, $wrapperState)
Write-Host ("Modal wrapper args @ 0x{0:X}: {1}" -f $sites.LegacyDeferredWrapper.Offset, $deferredWrapperState)
Write-Host ("Deferred hook cleanup @ 0x{0:X}: {1}" -f $sites.LegacyDeferredHook.Offset, $deferredHookState)
Write-Host ("Direct hook cleanup @ 0x{0:X}: {1}" -f $sites.Hook.Offset, $hookState)
Write-Host ''
Write-Host 'What this means:'
Write-Host '- Ctrl+Q still goes through the real 0x410 keyboard lane and keeps the original 0x844 cheat gate.'
if ($CtrlQPatched) {
Write-Host ('- The 0x410 body now correctly handles both cases: it arms an existing seg1408 debugger-state object in place, or lazily creates one and stores it at 0x659c/0x659e before arming break-next mode (+0x74=1, +0x75=0).')
Write-Host ("- {0} stops using the unsafe 1478:6597 data slot entirely and retargets the existing interpreter call at 1418:04b5 straight into the corrected private 13e8:232d stub." -f $activeProfile.Label)
if ($activeProfile.PatchCurrentUnitWrapper) {
Write-Host '- The current-unit debugger wrapper at 13a0:0086 has its inherited caller-word pushes zeroed so the callback does not forward the debugger-object pointer as UI arguments.'
}
if ($activeProfile.PatchModalWrapper) {
Write-Host '- The modal debugger wrapper at 13a0:020d has its inherited caller-word pushes zeroed for the same reason.'
}
}
else {
Write-Host '- All debugger patch changes are restored to the retail byte pattern, including the 0x410 body and the interpreter break call at 1418:04b5.'
Write-Host '- Older private-vtable and direct/deferred experiment sites are also written back to retail bytes during restore.'
}
Write-Host ''
}
function Invoke-MenuChoice {
param([string]$SelectedChoice)
switch ($SelectedChoice.Trim().ToLowerInvariant()) {
'1' {
Set-DesiredState -CtrlQPatched $true -ProfileKey 'candidate-o' -Label 'Ctrl+Q -> interpreter callsite modal stub patch (Candidate O)'
}
'2' {
Set-DesiredState -CtrlQPatched $true -ProfileKey 'candidate-p' -Label 'Ctrl+Q -> interpreter callsite current-unit stub patch (Candidate P)'
}
'candidate-i' {
Set-DesiredState -CtrlQPatched $true -ProfileKey 'candidate-o' -Label 'Ctrl+Q -> interpreter callsite modal stub patch (Candidate O)'
}
'candidate-j' {
Set-DesiredState -CtrlQPatched $true -ProfileKey 'candidate-p' -Label 'Ctrl+Q -> interpreter callsite current-unit stub patch (Candidate P)'
}
'candidate-m' {
Set-DesiredState -CtrlQPatched $true -ProfileKey 'candidate-o' -Label 'Ctrl+Q -> interpreter callsite modal stub patch (Candidate O)'
}
'candidate-n' {
Set-DesiredState -CtrlQPatched $true -ProfileKey 'candidate-p' -Label 'Ctrl+Q -> interpreter callsite current-unit stub patch (Candidate P)'
}
'candidate-o' {
Set-DesiredState -CtrlQPatched $true -ProfileKey 'candidate-o' -Label 'Ctrl+Q -> interpreter callsite modal stub patch (Candidate O)'
}
'candidate-p' {
Set-DesiredState -CtrlQPatched $true -ProfileKey 'candidate-p' -Label 'Ctrl+Q -> interpreter callsite current-unit stub patch (Candidate P)'
}
'3' {
Set-DesiredState -CtrlQPatched $false -ProfileKey $null -Label 'Restore original bytes'
}
'restore' {
Set-DesiredState -CtrlQPatched $false -ProfileKey $null -Label 'Restore original bytes'
}
2026-03-29 01:14:09 +01:00
'5' {
$fileBytes = [System.IO.File]::ReadAllBytes($exePath)
Assert-FirstMissionStartKnown -FileBytes $fileBytes
$currentConfig = Get-FirstMissionStartConfig -FileBytes $fileBytes
$egg = if ($PSBoundParameters.ContainsKey('FirstMissionEgg')) {
[int]$FirstMissionEgg.Value
}
else {
$null
}
if ($PSBoundParameters.ContainsKey('FirstMissionMap')) {
$map = [int]$FirstMissionMap
if ($null -eq $egg) {
$egg = [int]$currentConfig.Egg
}
}
else {
$inputValues = Read-FirstMissionRedirectInput -CurrentConfig $currentConfig
$map = [int]$inputValues.Map
$egg = [int]$inputValues.Egg
}
Set-FirstMissionStartConfig -Map $map -Egg $egg -Label ('Redirect fresh-game startup to map {0}, egg 0x{1:X4}' -f $map, $egg)
}
'map-redirect' {
$fileBytes = [System.IO.File]::ReadAllBytes($exePath)
Assert-FirstMissionStartKnown -FileBytes $fileBytes
$currentConfig = Get-FirstMissionStartConfig -FileBytes $fileBytes
if (-not $PSBoundParameters.ContainsKey('FirstMissionMap')) {
throw 'Choice map-redirect requires -FirstMissionMap, or use interactive menu option 5.'
}
$map = [int]$FirstMissionMap
$egg = if ($PSBoundParameters.ContainsKey('FirstMissionEgg')) {
[int]$FirstMissionEgg.Value
}
else {
[int]$currentConfig.Egg
}
Set-FirstMissionStartConfig -Map $map -Egg $egg -Label ('Redirect fresh-game startup to map {0}, egg 0x{1:X4}' -f $map, $egg)
}
'6' {
Set-FirstMissionStartConfig -Map ([int]$sites.FirstMissionStart.DefaultMap) -Egg ([int]$sites.FirstMissionStart.DefaultEgg) -Label 'Restore fresh-game startup default'
}
'map-default' {
Set-FirstMissionStartConfig -Map ([int]$sites.FirstMissionStart.DefaultMap) -Egg ([int]$sites.FirstMissionStart.DefaultEgg) -Label 'Restore fresh-game startup default'
}
'4' {
return $false
}
'exit' {
return $false
}
default {
Write-Warning 'Invalid selection.'
}
}
return $true
}
if (-not (Test-Path -LiteralPath $exePath)) {
throw "CRUSADER.EXE was not found at '$exePath'. Pass -ExePath to point at the retail install or place the EXE next to this script."
}
if ($PSBoundParameters.ContainsKey('Choice')) {
[void](Invoke-MenuChoice -SelectedChoice $Choice)
return
}
:mainloop while ($true) {
$currentBytes = [System.IO.File]::ReadAllBytes($exePath)
Show-Status -FileBytes $currentBytes
2026-03-29 01:14:09 +01:00
$choice = Read-Host 'Select 1-6 (4 exits)'
if ([string]::IsNullOrEmpty($choice)) { break mainloop }
if (-not (Invoke-MenuChoice -SelectedChoice $choice)) {
break mainloop
}
}