0.9.22: Intel 12th gen+ crash when hit (red-zone-safe stores, d4rksp4rt4n), plain ELF eboot.bin accepted

This commit is contained in:
Awaken committed 2026-10-14 06:23:23 +05:00
1 parent 6efa75cb2d
commit a04295f772
3 files changed
+487 -10

No files matched your search

+2
View File
@@ -52,6 +52,8 @@ at tag `v0.4.0-darkizuku-preview.1`.
| A scene above 1080p (output resolution and upscaler preset, without Live resolution) or a patch that enlarges the game's heaps (resolution patches above 1080p from the patches folder) gets the 9152 MiB pool; the output alone does not (it exists only on the host side), so a 1440p output with a scene up to 1080p keeps 5056 MiB; a full pool is logged and explained in the crash report | `BB_DMEM_MB` |
| Online: a network drop of the shadNet connection keeps the game signed in for 90 s while it reconnects (it returned to the title menu) | |
| Online co-op on The Hunter's Dream with bbhost players: the launcher starts bbbridge (MaskZeus, GPL-3.0-or-later, built unmodified, in `bbbridge\`) with the game and points the game's shadNet and server traffic at it; Discord sign-in on the Online page, account in `network\bbbridge\` | `online_pool=huntersdream` |
| Intel 12th gen and later: the game's stores that keep values just below the stack pointer run 128 bytes lower, so Windows' exception frames no longer overwrite them (the crash when hit); 607 sites, skipped where the bytes differ (d4rksp4rt4n, Supermedo/bloodborne_pc #2, `patches/redzone.json`) | |
| eboot.bin as a plain decrypted ELF is accepted as well as the PS4 SELF | |
| Quit game button in the in-game menu (Insert or L3+R3), pressed twice: the same clean exit as closing the window | |
| Online co-op: shadNet server framing for signaling, UPnP port mapping (Supermedo 004d2c37), P2P port 3658 by default, play logs to the game server | `SHADPS4_P2P_PORT` |
| In-game PC Colour page (brightness, contrast, saturation, hue from the game's own tone map) and a fog slider on PC Graphics, live; the same in the launcher's Picture tuning | `graphics_brightness`, `graphics_contrast`, `graphics_hue`, `graphics_fog` |
+484 -9
View File
@@ -23914,6 +23914,111 @@ index 0000000..2677d7e
+3395220681
+]
+}
diff --git a/patches/redzone.json b/patches/redzone.json
new file mode 100644
index 0000000..e38ac14
--- /dev/null
+++ b/patches/redzone.json
@@ -0,0 +1 @@
+{"eboot":"Bloodborne 01.09","text_sha256":"da9c1e6e6de298a07739a6d0bde7f6fa58198a0051b779dc241cbe9e9b6720ad","functions":153,"skipped":["0x3214c0 (jump table)","0x3bdcd0 (jump table)","0x40fc00 (jump table)","0x413b00 (jump table)","0x4c4d00 (jump table)","0x4c53a0 (jump table)","0x4c6ff0 (jump table)","0x5a71b0 (jump table)","0x781b60 (jump table)","0x787c30 (jump table)","0xd56840 (jump table)","0xd72690 (jump table)","0xd72a20 (jump table)","0xd72df0 (jump table)","0xd73310 (jump table)","0xd893a0 (jump table)","0xd8baf0 (jump table)","0xd8ed10 (jump table)","0xd99fd0 (jump table)","0xd9b6e0 (jump table)","0xd9c070 (jump table)","0xdc5b30 (jump table)","0xdc6700 (jump table)","0xf584c0 (jump table)","0xf5a5d0 (jump table)","0xffc220 (jump table)","0x15cb1f0 (jump table)","0x176f950 (jump table)","0x18dc900 (jump table)","0x210a700 (jump table)","0x2879e20 (jump table)"],"uncovered_stores":["0x6d5c5","0x1373e9","0x1373f0","0x1374b4","0x1374c4","0x137618","0x1376d6","0x1376dd","0x3b7031","0x3d10a8","0x3d157b","0x40b401","0x6db371","0x810356","0x810383","0xc62843","0xd4e640","0xd74582","0xdca513","0xe51128","0x17e44d6","0x17e44de","0x1a0d484","0x2b82e38","0x2b82e9d","0x2b82f9e","0x2b8300d","0x2b833b9","0x2b834fb","0x2b83e0a","0x2b83e4c","0x2b83fc0","0x2b84482","0x2b84627","0x2b85042","0x2b851a2","0x2b8532f","0x2b853ef","0x2b855b6","0x2b855dd","0x2b856e7","0x2b858a5"],"sites":[[82112,"6642c704692800",[-7]],[82135,"6642c704697d00",[-7]],[82156,"6642c704695d00",[-7]],[82174,"6642c704697b00",[-7]],[82192,"6642c704693c00",[-7]],[82210,"6642c704695b00",[-7]],[82797,"6642c704692900",[-7]],[82821,"6646893c69",[-5]],[82831,"468914a9488b4c24d042c6042900",[-4,5,-5]],[82897,"6642c704693e00",[-7]],[83032,"6644897c7d00418d343a8934bb41c6043800",[-6,4,-3,-5]],[83168,"48c741280000000048c7412000000000c7010000803fc741140000803fc741280000803f48c70001000000",[-8,-8,-6,-7,-7,-7]],[83235,"c7010000803f48c7410c0000000048c7410400000000c741140000803fc7411800000000c7411c0000000048c70001000000",[-6,-8,-8,-7,-7,-7,-7]],[491848,"488947184c896720488b4424e8488947284c8957304c8977388b4424e48947408b4424f48947448b4424f88947488b4424fc89474c8857504c894f08",[-4,-4,5,-4,-4,-4,4,-3,4,-3,4,-3,4,-3,-3,-4]],[801876,"418881d0000000498d99d3000000418a81d3000000418881d1000000418a81d4000000418881d2000000418a81d5000000418881d3000000",[-7,7,7,-7,7,-7,7,-7]],[802845,"6641c781d40000000000",[-10]],[802870,"418881d6000000418a91d8000000418891d7000000",[-7,7,-7]],[803166,"41c681d500000001",[-8]],[803257,"41c681d400000000",[-8]],[803378,"6641c781d40000000000",[-10]],[803444,"41c681d400000001",[-8]],[803541,"41c681d500000000",[-8]],[803556,"418881d900000084d2490f44da8a032401418881da000000",[-7,2,4,2,2,-7]],[803770,"418881da000000",[-7]],[847056,"4c897cd5004c8974d50866c744d512000066894cd5104c897cd5184c8964d52066c744d52a000066448954d52883c3026641895c8512",[-5,-5,-7,-5,-5,-5,-7,-6,3,-6]],[875519,"88084803442498",[-2,5]],[875655,"41880c284401d6",[-4,3]],[876019,"41880c2801f8",[-4,2]],[876411,"438814a84c89e2c1ed1443886ca801c1e91443884ca802c1e814438844a803",[-4,3,3,-5,3,-5,3,-5]],[876667,"43881498c1ee0a4388749801c1ed0a43886c9802c1e90a43884c9803",[-4,3,-5,3,-5,3,-5]],[876922,"8848fdc1ed0a408868fec1eb0a8858ff4c89eb4d89c5c1ea0a8810",[-3,3,-4,3,-3,3,3,3,-2]],[1140937,"881948ffc1",[-2,3]],[1152153,"488987f8000000",[-7]],[1152517,"66899ff8000000668987fa00000066898ffc00000066448987fe00000085d20f9fc20fb6d2ffc2899700010000",[-7,-7,-7,-8,2,3,3,2,-6]],[1186680,"6689047198",[-4,1]],[1186803,"6641890c68",[-5]],[1186920,"66890479ffc6",[-4,2]],[1186937,"66428b0461668901",[5,-3]],[1274660,"c706ff3f0000c702ff3f0000c70101c0ffff41c70001c0ffff",[-6,-6,-6,-7]],[1274830,"488b5424f0893a",[5,-2]],[1274846,"488b5424f88902",[5,-2]],[1275033,"488b5424f0892a",[5,-2]],[1275049,"488b5424f8893a",[5,-2]],[1275389,"488b7c24f0891f",[5,-2]],[1275405,"488b7c24f8892f",[5,-2]],[1275423,"41892866666666662e0f1f840000000000",[-3,14]],[1275579,"488b5424f0891a",[5,-2]],[1275595,"488b5424f8893a",[5,-2]],[3744672,"48c741280000000048c7412000000000c7010000803fc741140000803fc741280000803f48c70001000000",[-8,-8,-6,-7,-7,-7]],[3744739,"c7010000803f48c7410c0000000048c7410400000000c741140000803fc7411800000000c7411c0000000048c70001000000",[-6,-8,-8,-7,-7,-7,-7]],[3831463,"89b7a0000000",[-6]],[3854612,"884bfe8a4c03ff884bff8a0c03880b",[-3,4,-3,3,-2]],[3854673,"4288443604",[-5]],[3854705,"4288443605",[-5]],[3855203,"880b48ffc3",[-2,3]],[3855490,"880e48ffc6",[-2,3]],[3855735,"880b48ffc3",[-2,3]],[3855827,"884efe8a4bff884eff8a0b880e",[-3,3,-3,2,-2]],[3855888,"41884e0183f802",[-4,3]],[3855901,"418846024983c602",[-4,4]],[3856092,"41884d0049ffc5",[-4,3]],[3856304,"41884e0149ffc6",[-4,3]],[3856331,"c7000b000000",[-6]],[3856346,"48894730488b442448c7001d000000",[-4,5,-6]],[3856411,"48890f488977184c39da4929d34183c30544895f084d39ce4d29f14181c10101000044894f20488b4c24484889414844894150",[-3,-4,3,3,4,-4,3,3,7,-4,5,-4,-4]],[3856484,"48894f30488b4c2448c7011d000000",[-4,5,-6]],[3856519,"48894730488b442448c7001d000000",[-4,5,-6]],[3877551," Line truncated
diff --git a/scripts/link_modules.py b/scripts/link_modules.py
index b0a76bd..1d289fb 100644
--- a/scripts/link_modules.py
+++ b/scripts/link_modules.py
@@ -10,9 +10,11 @@ libSceLibcInternal are served by libc.prx's same-NID exports: the internal
library is not shipped with the game and exposes the same functions.
Output: out/boot-linked.bin (format BBPROBE5) and out/link.json.
"""
+import bisect
import collections
import hashlib
import json
+import os
from pathlib import Path
import struct
from prepare import parse_self, span, unpack
@@ -20,6 +22,9 @@ from link_libc import encode_id
DEFAULT_MODULES = ('libc.prx', 'libSceFios2.prx')
FS_LOAD = bytes.fromhex('64488b042500000000') # mov rax, fs:[0]
+RED_ZONE_SITES = Path(__file__).resolve().parent.parent / 'patches' / 'redzone.json'
+LEA_DOWN = bytes.fromhex('488d642480') # lea rsp, [rsp-0x80]
+LEA_UP = bytes.fromhex('488da42480000000') # lea rsp, [rsp+0x80]
def module(path):
@@ -89,7 +94,40 @@ def patch_fs_loads(image, ph, base):
return patched
-def link(game, out, module_names=DEFAULT_MODULES):
+def protect_red_zones(image, sites, base, skip=()):
+ """Run guest stores of red-zone leaf functions with rsp 128 bytes lower (Windows).
+
+ Where Windows saves extended state in the compact XSAVE layout (Intel 12th gen and later),
+ an exception's frame holds the AVX registers up to 0x28 bytes below rsp, over the System V
+ red zone where leaf functions keep live locals (PS4 and Linux leave those 128 bytes alone).
+ A store that faults on a page the GPU tracker write-protected is resumed with the locals
+ overwritten: the attack-trail writer at 0x28ce7b0 then loads NULL from [rsp-0x60]. Each site
+ (tools/redzone_sites.py: offset, original bytes, instruction lengths, negative = store)
+ jumps to a copy at `base` where the stores run below the red zone. Sites whose bytes
+ differ or that overlap a relocation (`skip`: sorted targets) stay as they are. Returns
+ the copies and the number of sites redirected."""
+ code = bytearray()
+ applied = 0
+ for offset, original, lengths in sites:
+ original = bytes.fromhex(original)
+ end = offset + len(original)
+ near = bisect.bisect_right(skip, offset - 8)
+ if image[offset:end] != original or (near < len(skip) and skip[near] < end):
+ continue
+ here = base + len(code)
+ pos = offset
+ for length in lengths:
+ body = image[pos:pos + abs(length)]
+ code += LEA_DOWN + body + LEA_UP if length < 0 else body
+ pos += abs(length)
+ code += b'\xe9' + struct.pack('<i', end - (base + len(code) + 5))
+ code += b'\xcc' * (-len(code) % 16)
+ image[offset:end] = b'\xe9' + struct.pack('<i', here - (offset + 5)) + b'\xcc' * (len(original) - 5)
+ applied += 1
+ return bytes(code), applied
+
+
+def link(game, out, module_names=DEFAULT_MODULES, red_zones=os.name == 'nt'):
main = module(game / 'eboot.bin')
raw = (out / 'boot.bin').read_bytes()
magic, size, entry, ns, nr, ni, flags = unpack('<8s6Q', raw, 0)
@@ -196,6 +234,14 @@ def link(game, out, module_names=DEFAULT_MODULES):
tls_relocations=tls_relocations, exports=count, sha256=m['sha256']))
base = (base + modsize + 65535) & ~65535
+ red_zone_sites = 0
+ if red_zones and RED_ZONE_SITES.is_file():
+ sites = json.loads(RED_ZONE_SITES.read_text())['sites']
+ code, red_zone_sites = protect_red_zones(image, sites, base, sorted(r[0] for r in relocs))
+ if code:
+ image.extend(bytes(base - len(image)) + code)
+ segments.append((base, len(code), 5))
+
bindings, unresolved = [], []
for identity, index in identities.items():
found = exports.get(identity)
@@ -235,12 +281,13 @@ def link(game, out, module_names=DEFAULT_MODULES):
f.write(image)
report = dict(modules=[{k: (hex(v) if k in ('base', 'init', 'tls_address') else v) for k, v in t.items()} for t in table],
bindings=len(bindings), imports=len(names), fs_loads_patched=fs_patched,
+ red_zone_sites=red_zone_sites,
main_tls=dict(zip(('vaddr', 'filesz', 'memsz', 'align'), main_tls_values)),
unresolved_imports=unresolved)
(out / 'link.json').write_text(json.dumps(report, indent=2) + '\n')
summary = ', '.join(f"{t['file']}@{t['base']:#x}" for t in table)
print(f'Linked modules: {summary}; {len(bindings)} native bindings, {len(unresolved)} imports left to the host runtime, '
- f'fs->gs patched={fs_patched}')
+ f'fs->gs patched={fs_patched}, red-zone-safe stores at {red_zone_sites} sites')
if __name__ == '__main__':
diff --git a/scripts/mods.py b/scripts/mods.py
index d6818eb..ceca08e 100644
--- a/scripts/mods.py
@@ -24484,8 +24589,51 @@ index da1e52e..eaeb424 100644
# BBPATCH2: the patch base, so the loader can rebase pointers the patches write into
# relocated slots (60/90 FPS++ replace function pointers).
blob=struct.pack('<8sQQ',b'BBPATCH2',EBOOT_BASE,len(writes))
diff --git a/scripts/prepare.py b/scripts/prepare.py
index 9860c61..f46e297 100644
--- a/scripts/prepare.py
+++ b/scripts/prepare.py
@@ -25,9 +25,37 @@ def unpack(fmt, data, offset):
return struct.unpack(fmt, span(data, offset, struct.calcsize(fmt)))
+def parse_elf(data):
+ """An eboot.bin already unwrapped to a plain ELF (some dumping tools and other ports write
+ it so): the same result as parse_self, every segment present in the file."""
+ header = unpack('<16sHHIQQQIHHHHHH', data, 0)
+ if header[0][:7] != b'\x7fELF\x02\x01\x01' or header[2] != 62:
+ raise ValueError("expected little-endian x86-64 ELF")
+ if header[9] != 56 or not 0 < header[10] < 256:
+ raise ValueError("unsupported program headers")
+ ph = [dict(zip(('type', 'flags', 'offset', 'vaddr', 'paddr', 'filesz', 'memsz', 'align'),
+ unpack('<IIQQQQQQ', data, header[5] + i * 56)))
+ for i in range(header[10])]
+ end = max(p['offset'] + p['filesz'] for p in ph)
+ if end > 512 * 1024 * 1024:
+ raise ValueError("probe image exceeds 512 MiB limit")
+ elf = bytearray(end)
+ present = min(end, len(data))
+ elf[:present] = data[:present]
+ missing = []
+ for i, p in enumerate(ph):
+ if p['filesz'] and p['offset'] + p['filesz'] > len(data):
+ missing.append(i)
+ if p['type'] in (1, 2, 0x61000000, 0x61000010):
+ raise ValueError(f"required segment {i} is unavailable")
+ return elf, header, ph, [], missing
+
+
def parse_self(data):
+ if span(data, 0, 4) == b'\x7fELF':
+ return parse_elf(data)
if span(data, 0, 4) != b'O\x15=\x1d':
- raise ValueError("expected PS4 SELF")
+ raise ValueError("expected a PS4 executable (SELF or ELF)")
count, = unpack('<H', data, 24)
base = 32 + count * 32
header = unpack('<16sHHIQQQIHHHHHH', data, base)
diff --git a/scripts/run_windows.py b/scripts/run_windows.py
index ea6daf1..6413fc4 100644
index ea6daf1..04eec28 100644
--- a/scripts/run_windows.py
+++ b/scripts/run_windows.py
@@ -10,12 +10,15 @@ The game folder: --game-dir, else BB_GAME_DIR, else the last one used (out/game_
@@ -24529,7 +24677,7 @@ index ea6daf1..6413fc4 100644
def run(arguments, capture=False, check=True, env=None):
result = subprocess.run(
[str(a) for a in arguments],
@@ -78,6 +99,50 @@ def build():
@@ -78,6 +99,52 @@ def build():
run([bash, '-lc', 'bash build.sh'], env=env)
@@ -24545,7 +24693,9 @@ index ea6daf1..6413fc4 100644
+def prepare_key(game, sku):
+ digest = hashlib.sha256()
+ modules = sorted((game / 'sce_module').iterdir()) if (game / 'sce_module').is_dir() else []
+ for path in [game / 'eboot.bin', game / 'sce_sys/param.sfo', *modules, *(SCRIPTS / s for s in PREPARE_SCRIPTS)]:
+ # patches/redzone.json: the stores link_modules.py moves below the red zone (Intel 12th gen+).
+ for path in [game / 'eboot.bin', game / 'sce_sys/param.sfo', *modules, *(SCRIPTS / s for s in PREPARE_SCRIPTS),
+ ROOT / 'patches' / 'redzone.json']:
+ digest.update(path.name.encode('utf-8') + b'\0')
+ if path.is_file():
+ with path.open('rb') as f:
@@ -24580,7 +24730,7 @@ index ea6daf1..6413fc4 100644
def settings_value(config, key):
if not config.is_file():
return None
@@ -95,6 +160,18 @@ def configure_frame_rate(env):
@@ -95,6 +162,18 @@ def configure_frame_rate(env):
legacy uncapped launch cannot saturate the desktop during menus or loading.
"""
fps = env.get('BB_FPS', '60')
@@ -24599,7 +24749,7 @@ index ea6daf1..6413fc4 100644
if fps not in ('30', '60', '90'):
print(f'Unsupported Windows FPS preset {fps!r}; using 60 FPS.', flush=True)
fps = '60'
@@ -102,6 +179,56 @@ def configure_frame_rate(env):
@@ -102,6 +181,56 @@ def configure_frame_rate(env):
return fps
@@ -24656,7 +24806,7 @@ index ea6daf1..6413fc4 100644
def main():
arguments = sys.argv[1:]
game = os.environ.get('BB_GAME_DIR')
@@ -138,39 +265,45 @@ def main():
@@ -138,39 +267,45 @@ def main():
'--config', os.environ.get('BB_MODS_CONFIG', data / 'mods.json'),
'--enabled', os.environ.get('BB_MODS_ENABLED', '1')], capture=True))
overlay = merged if merged.resolve() != original else None
@@ -24728,7 +24878,7 @@ index ea6daf1..6413fc4 100644
# Sizes chosen below for the previous launch are recomputed after an in-game restart.
if os.environ.get('BB_AUTO_RENDER_RES') == '1':
for key in ('BB_RENDER_RES', 'BB_OUTPUT_RES', 'BB_AUTO_RENDER_RES'):
@@ -192,14 +325,30 @@ def main():
@@ -192,14 +327,30 @@ def main():
print(f'Output {scaled_output}: live resolution changes (live_resolution=0: startup patch)')
elif scaled_output:
os.environ.update(BB_RENDER_RES=scaled_render, BB_OUTPUT_RES=scaled_output, BB_AUTO_RENDER_RES='1')
@@ -24761,7 +24911,7 @@ index ea6daf1..6413fc4 100644
probe = ROOT / os.environ.get('BB_PROBE', out / 'bb-probe.exe')
command = [probe, out / 'boot-linked.bin', '--content-profile', out / 'content.bin',
'--patches', out / 'patches.bin', '--app0', merged,
@@ -208,6 +357,10 @@ def main():
@@ -208,6 +359,10 @@ def main():
# MSYS2's DLLs (libc++, SDL3, FFmpeg, ...). System32 is searched before PATH, so the
# Vulkan loader stays the one installed with the GPU driver.
os.environ['PATH'] = os.pathsep.join([str(msys_root() / 'clang64/bin'), os.environ.get('PATH', '')])
@@ -24772,7 +24922,7 @@ index ea6daf1..6413fc4 100644
print('Starting:', ' '.join(shlex.quote(str(c)) for c in command), flush=True)
try:
status = subprocess.call(
@@ -219,8 +372,11 @@ def main():
@@ -219,8 +374,11 @@ def main():
except KeyboardInterrupt:
status = 130
print(f'bb-probe exited with code {status}', flush=True)
@@ -27789,6 +27939,148 @@ index 0000000..b14cae2
+ std::printf("pipeline compile tests passed\n");
+ return 0;
+}
diff --git a/tests/test_redzone.py b/tests/test_redzone.py
new file mode 100644
index 0000000..b7ddf6c
--- /dev/null
+++ b/tests/test_redzone.py
@@ -0,0 +1,136 @@
+"""Red-zone-safe trampolines for guest stores (link_modules.protect_red_zones).
+
+Where Windows saves extended state in the compact XSAVE layout (Intel 12th gen and later), a
+fault's frame holds the AVX registers up to 0x28 bytes below rsp, over the System V red zone, so
+a guest leaf function whose store faults on a GPU-tracked page loses its locals (the
+attack-trail crash at 0x28ce9b5). The Windows test runs that sequence for real: a vectored
+handler unprotects the page as the GPU tracker does.
+"""
+from paths import ROOT
+import ctypes
+import json
+import os
+import struct
+import unittest
+import xml.etree.ElementTree as ET
+from link_modules import LEA_DOWN, LEA_UP, protect_red_zones
+
+SENTINEL = 0x1122334455667788
+STORE = 30 # offset of the faulting store in leaf()
+
+
+def leaf(page):
+ """vcmptrueps ymm0 (AVX state in use); mov rax,SENTINEL; mov [rsp-0x40],rax; mov rcx,page;
+ mov dword [rcx],1; mov rax,[rsp-0x40]; vzeroupper; ret"""
+ code = (bytes.fromhex('c5fcc2c00f') + b'\x48\xb8' + struct.pack('<Q', SENTINEL) +
+ bytes.fromhex('48894424c0') + b'\x48\xb9' + struct.pack('<Q', page) +
+ bytes.fromhex('c70101000000') + bytes.fromhex('488b4424c0' 'c5f877' 'c3'))
+ return code, [[STORE, 'c70101000000', [-6]]]
+
+
+class TrampolineBytes(unittest.TestCase):
+ def test_site_jumps_to_trampoline_that_lowers_rsp_around_the_store(self):
+ image = bytearray(leaf(0)[0])
+ code, applied = protect_red_zones(image, leaf(0)[1], 0x100)
+ self.assertEqual(applied, 1)
+ self.assertEqual(image[STORE:STORE + 5], b'\xe9' + struct.pack('<i', 0x100 - (STORE + 5)))
+ self.assertEqual(image[STORE + 5], 0xcc)
+ body = LEA_DOWN + bytes.fromhex('c70101000000') + LEA_UP
+ self.assertEqual(code[:len(body)], body)
+ back = 0x100 + len(body) + 5
+ self.assertEqual(code[len(body):len(body) + 5], b'\xe9' + struct.pack('<i', STORE + 6 - back))
+
+ def test_other_instructions_of_a_site_are_copied_unchanged(self):
+ image = bytearray(bytes.fromhex('8b442404' '8946a4' 'c3')) # mov eax,[rsp+4]; mov [rsi-0x5c],eax
+ code, applied = protect_red_zones(image, [[0, '8b4424048946a4', [4, -3]]], 0x40)
+ self.assertEqual(applied, 1)
+ self.assertTrue(code.startswith(bytes.fromhex('8b442404') + LEA_DOWN + bytes.fromhex('8946a4') + LEA_UP))
+
+ def test_changed_bytes_or_relocations_leave_the_site_alone(self):
+ image = bytearray(leaf(0)[0])
+ self.assertEqual(protect_red_zones(image, [[STORE, 'c70102000000', [-6]]], 0x100), (b'', 0))
+ self.assertEqual(protect_red_zones(image, leaf(0)[1], 0x100, skip=[STORE + 3]), (b'', 0))
+ self.assertEqual(image, leaf(0)[0])
+
+
+class SiteTable(unittest.TestCase):
+ sites = json.loads((ROOT / 'patches' / 'redzone.json').read_text())['sites']
+
+ def test_attack_trail_stores_are_covered(self):
+ # 0x28ce7b0 writes trail vertices with live locals at [rsp-0x6c, rsp); 0x28ce9b5 then
+ # loaded a NULL pointer from the overwritten [rsp-0x60].
+ covered = set()
+ for offset, original, lengths in self.sites:
+ pos = offset
+ for length in lengths:
+ if length < 0:
+ covered.add(pos)
+ pos += abs(length)
+ for store in (0x28cedfe, 0x28cee33, 0x28cee68, 0x28cee72):
+ self.assertIn(store, covered)
+
+ def test_sites_do_not_overlap_community_patches(self):
+ lines = ET.parse(ROOT / 'patches' / 'Bloodborne.xml').iter('Line')
+ patched = sorted(int(line.get('Address'), 16) - 0x400000 for line in lines)
+ for offset, original, _ in self.sites:
+ end = offset + len(original) // 2
+ self.assertFalse(any(offset - 8 < p < end for p in patched), hex(offset))
+
+
[email protected](os.name == 'nt', 'Windows exception dispatch')
+class WindowsFault(unittest.TestCase):
+ """A store faults on a read-only page; the handler makes it writable and resumes."""
+
+ def run_leaf(self, protect):
+ k32 = ctypes.WinDLL('kernel32', use_last_error=True)
+ k32.VirtualAlloc.restype = ctypes.c_void_p
+ k32.VirtualAlloc.argtypes = (ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint32, ctypes.c_uint32)
+ k32.VirtualProtect.argtypes = (ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint32, ctypes.POINTER(ctypes.c_uint32))
+ k32.VirtualFree.argtypes = (ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint32)
+ k32.AddVectoredExceptionHandler.restype = ctypes.c_void_p
+ k32.RemoveVectoredExceptionHandler.argtypes = (ctypes.c_void_p,)
+ page = k32.VirtualAlloc(None, 4096, 0x3000, 0x02) # MEM_COMMIT|MEM_RESERVE, PAGE_READONLY
+ text = k32.VirtualAlloc(None, 4096, 0x3000, 0x40) # PAGE_EXECUTE_READWRITE
+ code, sites = leaf(page)
+ image = bytearray(code)
+ if protect:
+ trampolines, applied = protect_red_zones(image, sites, 0x100)
+ self.assertEqual(applied, 1)
+ image += bytes(0x100 - len(image)) + trampolines
+ ctypes.memmove(text, bytes(image), len(image))
+
+ class Record(ctypes.Structure):
+ _fields_ = [('code', ctypes.c_uint32), ('flags', ctypes.c_uint32), ('next', ctypes.c_void_p),
+ ('address', ctypes.c_void_p), ('count', ctypes.c_uint32),
+ ('information', ctypes.c_size_t * 15)]
+
+ class Pointers(ctypes.Structure):
+ _fields_ = [('record', ctypes.POINTER(Record)), ('context', ctypes.c_void_p)]
+
+ @ctypes.WINFUNCTYPE(ctypes.c_long, ctypes.POINTER(Pointers))
+ def handler(info):
+ r = info.contents.record.contents
+ if r.code != 0xC0000005 or not page <= r.information[1] < page + 4096:
+ return 0 # EXCEPTION_CONTINUE_SEARCH
+ k32.VirtualProtect(page, 4096, 0x04, ctypes.byref(ctypes.c_uint32()))
+ return -1 # EXCEPTION_CONTINUE_EXECUTION
+
+ cookie = k32.AddVectoredExceptionHandler(1, handler)
+ try:
+ result = ctypes.CFUNCTYPE(ctypes.c_uint64)(text)()
+ finally:
+ k32.RemoveVectoredExceptionHandler(cookie)
+ k32.VirtualFree(text, 0, 0x8000)
+ k32.VirtualFree(page, 0, 0x8000)
+ return result
+
+ def test_exception_frame_overwrites_the_red_zone(self):
+ if self.run_leaf(protect=False) == SENTINEL:
+ self.skipTest('this CPU keeps the red zone: its AVX state is saved further down')
+
+ def test_trampoline_keeps_the_red_zone(self):
+ self.assertEqual(self.run_leaf(protect=True), SENTINEL)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_settings.cpp b/tests/test_settings.cpp
index 593fed4..2087d68 100644
--- a/tests/test_settings.cpp
@@ -28218,3 +28510,186 @@ index 2199381..8054a0c 100644
+117045 5120x2160 (21:9)
+117046 7680x2160 (32:9)
+117047 Display (auto)
diff --git a/tools/redzone_sites.py b/tools/redzone_sites.py
new file mode 100644
index 0000000..a3e6357
--- /dev/null
+++ b/tools/redzone_sites.py
@@ -0,0 +1,177 @@
+"""List the eboot stores that run below the red zone on Windows: patches/redzone.json.
+
+Where Windows saves extended state in the compact XSAVE layout (Intel 12th gen and later), an
+exception's frame holds the AVX registers up to 0x28 bytes below rsp, over the System V red
+zone, so a leaf function's store that faults on a GPU-tracked page and is resumed loses the
+locals the function keeps there (link_modules.protect_red_zones). A site is a run of a red-zone
+leaf's instructions, at least 5 bytes for a jmp, holding its stores through non-stack pointers;
+nothing inside it may be a branch target, rip-relative, segment-prefixed or change rsp.
+Functions with jump tables or community patches (patches/Bloodborne.xml) are left out.
+
+Developer tool (pip install capstone): python tools/redzone_sites.py <game dir with eboot.bin>
+"""
+import argparse
+import hashlib
+import json
+from pathlib import Path
+import re
+import struct
+import sys
+import xml.etree.ElementTree as ET
+import capstone
+from capstone import x86
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT / 'scripts'))
+from prepare import parse_self # noqa: E402
+
+RSP, RBP, RIP = x86.X86_REG_RSP, x86.X86_REG_RBP, x86.X86_REG_RIP
+STACK = {RSP, x86.X86_REG_ESP, x86.X86_REG_SP, x86.X86_REG_SPL}
+FLOW = {capstone.CS_GRP_JUMP, capstone.CS_GRP_CALL, capstone.CS_GRP_RET, capstone.CS_GRP_INT, capstone.CS_GRP_IRET}
+NO_ACCESS = ('lea', 'nop', 'prefetch')
+RED_ZONE_RSP = re.compile(rb'[\x44\x4c\x54\x5c\x64\x6c\x74\x7c]\x24[\x80-\xff]', re.S) # [rsp-disp8]
+FRAME_POINTER = bytes.fromhex('4889e5') # mov rbp, rsp
+
+
+def load(game):
+ elf, _, ph, _, _ = parse_self((game / 'eboot.bin').read_bytes())
+ loads = [p for p in ph if p['type'] == 1]
+ image = bytearray(max(p['vaddr'] + p['memsz'] for p in loads))
+ for p in loads:
+ image[p['vaddr']:p['vaddr'] + p['filesz']] = elf[p['offset']:p['offset'] + p['filesz']]
+ text = next(p for p in loads if p['flags'] & 1)
+ hdr = next(p for p in ph if p['type'] == 0x6474e550)['vaddr']
+ if image[hdr + 2] != 0x03 or image[hdr + 3] != 0x3b:
+ raise ValueError('unexpected .eh_frame_hdr encoding')
+ count, = struct.unpack_from('<I', image, hdr + 8)
+ starts = sorted({hdr + struct.unpack_from('<i', image, hdr + 12 + 8 * i)[0] for i in range(count)})
+ end = text['vaddr'] + text['filesz']
+ return bytes(image), [s for s in starts if text['vaddr'] <= s < end] + [end]
+
+
+def patched_addresses():
+ lines = ET.parse(ROOT / 'patches' / 'Bloodborne.xml').iter('Line')
+ return sorted(int(line.get('Address'), 16) - 0x400000 for line in lines)
+
+
+def stack_layout(insns):
+ """rsp after the prologue and rbp as a frame pointer, both relative to the entry rsp."""
+ rsp, rbp = 0, None
+ for i in insns:
+ ops = i.operands
+ if i.mnemonic == 'push':
+ rsp -= 8
+ elif i.mnemonic == 'sub' and ops[0].type == x86.X86_OP_REG and ops[0].reg == RSP and ops[1].type == x86.X86_OP_IMM:
+ rsp -= ops[1].imm
+ elif i.mnemonic == 'mov' and ops[0].type == x86.X86_OP_REG and ops[0].reg == RBP and \
+ ops[1].type == x86.X86_OP_REG and ops[1].reg == RSP:
+ rbp = rsp
+ else:
+ break
+ return rsp, rbp
+
+
+def movable(i):
+ _, written = i.regs_access()
+ return not FLOW & set(i.groups) and not STACK & set(written) and i.prefix[1] not in (0x64, 0x65) and \
+ not any(op.type == x86.X86_OP_MEM and op.mem.base == RIP for op in i.operands)
+
+
+def analyse(md, image, start, end):
+ """(sites, uncovered stores) of one function; None when it keeps nothing below rsp."""
+ insns = []
+ for i in md.disasm(image[start:end], start):
+ if i.group(capstone.CS_GRP_CALL):
+ return None # not a leaf: no red zone
+ insns.append(i)
+ if not insns:
+ return None
+ body_rsp, rbp = stack_layout(insns)
+ targets, red_zone, stores = set(), False, []
+ for i in insns:
+ if i.group(capstone.CS_GRP_JUMP):
+ op = i.operands[0]
+ if op.type == x86.X86_OP_IMM:
+ targets.add(op.imm)
+ elif not (op.type == x86.X86_OP_MEM and op.mem.base == RIP):
+ return [], ['jump table'] # targets unknown: left alone
+ for op in i.operands:
+ if op.type != x86.X86_OP_MEM:
+ continue
+ if op.mem.base == RSP and op.mem.disp < 0:
+ red_zone = True
+ if rbp is not None and op.mem.base == RBP and rbp + op.mem.disp < body_rsp:
+ red_zone = True
+ if op.access & capstone.CS_AC_WRITE and not i.mnemonic.startswith(NO_ACCESS) and \
+ op.mem.base not in (RSP, RIP) and not (rbp is not None and op.mem.base == RBP):
+ stores.append(i)
+ if not red_zone or not stores:
+ return None
+ # Runs of movable instructions; a branch target starts a new run.
+ runs, run = [], []
+ for i in insns:
+ if not movable(i) or (i.address in targets and run):
+ if run:
+ runs.append(run)
+ run = [i] if movable(i) else []
+ continue
+ run.append(i)
+ if run:
+ runs.append(run)
+ store_at = {i.address for i in stores}
+ sites = []
+ for run in runs:
+ marked = [k for k, i in enumerate(run) if i.address in store_at]
+ if not marked:
+ continue
+ first, last = marked[0], marked[-1]
+ while sum(i.size for i in run[first:last + 1]) < 5 and (last + 1 < len(run) or first > 0):
+ if last + 1 < len(run):
+ last += 1
+ else:
+ first -= 1
+ span = run[first:last + 1]
+ if sum(i.size for i in span) >= 5:
+ sites.append([span[0].address, b''.join(i.bytes for i in span).hex(),
+ [-i.size if i.address in store_at else i.size for i in span]])
+ covered = {a for s in sites for a in range(s[0], s[0] + len(s[1]) // 2)}
+ return sites, [f'{i.address:#x}' for i in stores if i.address not in covered]
+
+
+def main():
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ p.add_argument('game', type=Path)
+ p.add_argument('-o', '--out', type=Path, default=ROOT / 'patches' / 'redzone.json')
+ a = p.parse_args()
+ image, starts = load(a.game)
+ patched = patched_addresses()
+ md = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_64)
+ md.detail = True
+ sites, functions, skipped, uncovered = [], 0, [], []
+ for start, end in zip(starts, starts[1:]):
+ code = image[start:end]
+ if not (RED_ZONE_RSP.search(code) or FRAME_POINTER in code):
+ continue
+ result = analyse(md, image, start, end)
+ if result is None:
+ continue
+ if any(start <= q < end for q in patched):
+ skipped.append(f'{start:#x} (community patch)')
+ continue
+ found, missing = result
+ if missing == ['jump table']:
+ skipped.append(f'{start:#x} (jump table)')
+ continue
+ functions += 1
+ sites += found
+ uncovered += missing
+ a.out.write_text(json.dumps(dict(
+ eboot='Bloodborne 01.09', text_sha256=hashlib.sha256(image[starts[0]:starts[-1]]).hexdigest(),
+ functions=functions, skipped=skipped, uncovered_stores=uncovered,
+ sites=sites), separators=(',', ':')) + '\n', encoding='ascii')
+ print(f'{len(sites)} sites in {functions} red-zone leaf functions; {len(uncovered)} stores uncovered; '
+ f'{len(skipped)} functions skipped -> {a.out}')
+
+
+if __name__ == '__main__':
+ main()
@@ -26,7 +26,7 @@ namespace BloodborneLauncher.Core
public static string ReleasesPage => $"https://github.com/{Repository}/releases";
/// <summary>The engine build this launcher is made for (package.json "engine").</summary>
public const string EngineId = "bbhost-0.4.0-fps35";
public const string EngineId = "bbhost-0.4.0-fps36";
private const string ExeName = "Bloodborne Launcher.exe";
private const string PackageFile = "package.json";