mirror of
https://github.com/Awakenchan/LanternEngine.git
synced 2026-11-11 01:11:43 -10:00
788 lines
37 KiB
Python
788 lines
37 KiB
Python
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
"""Compile selected shadPS4/GoldHEN XML patches into out/patches.bin for the loader.
|
|
|
|
Patch addresses are PS4 virtual addresses (eboot base 0x400000); the loader's
|
|
image places eboot vaddr 0 at image offset 0. Only literal writes are supported
|
|
(bytes, bytes16/32/64, float32/64, utf8, utf16); pattern ("mask") patches are rejected.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import struct
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
import zlib
|
|
from pathlib import Path
|
|
|
|
EBOOT_BASE=0x400000
|
|
FPS_PRESETS={'30':[],'60':['60 FPS++'],'90':['90 FPS++','Sprint Fix (High FPS)'],
|
|
'uncap':['Uncap FPS++','Sprint Fix (High FPS)']}
|
|
SCALED_FPS={'120':120,'144':144}
|
|
SCALED_BASE='90 FPS++'
|
|
|
|
|
|
def scaled_rate_values(fps):
|
|
"""Patch address -> bytes: the 90 FPS++ values that depend on the frame rate, for `fps`."""
|
|
f=lambda *v: b''.join(struct.pack('<f',x) for x in v)
|
|
frame=bytes.fromhex('41c7442418')+f(1/fps)
|
|
cap=struct.pack('<I',{90:10000,120:8000,144:6500}[fps])
|
|
return {
|
|
0x0F383CA: f(1/fps),
|
|
0x1CBDB42: bytes.fromhex('c7857cffffff')+f(0.0125*90/fps)+bytes.fromhex('909090909090'),
|
|
0x1929676: struct.pack('<i',fps//6),
|
|
0x192C405: struct.pack('<i',fps//3),
|
|
0x2434840: frame, 0x243485A: frame, 0x243487E: frame, 0x24348B3: frame,
|
|
0x25629F0: b'\xbf'+struct.pack('<I',990000//fps),
|
|
0x2C012CC: b'\x81\xff'+cap+b'\x7e\x05\xbf'+cap+bytes.fromhex('e98aeb3b00'),
|
|
0x4CF6420: f(*[3/fps]*4),
|
|
0x4D27FD4: f(-3/fps), 0x4D27FE0: f(3/fps),
|
|
0x4D29170: f(1/fps),
|
|
}
|
|
|
|
|
|
EFFECT_SECOND_STEP=0x4D27C4C
|
|
|
|
|
|
def rate_scaled(writes, fps):
|
|
"""The 90 FPS++ writes with their frame-rate values for `fps`."""
|
|
values=scaled_rate_values(fps)
|
|
sixtieth,step=struct.pack('<f',1/60),struct.pack('<f',1/fps)
|
|
step_fix=os.environ.get('BB_FRAME_STEP_FIX','1')!='0'
|
|
out,seen=[],set()
|
|
for offset,data in writes:
|
|
value=values.get(offset+EBOOT_BASE)
|
|
if step_fix and value is None and data==sixtieth and offset+EBOOT_BASE!=EFFECT_SECOND_STEP:
|
|
out.append((offset,step))
|
|
continue
|
|
if value is not None:
|
|
if len(value)!=len(data): raise ValueError(f'{SCALED_BASE}: {offset+EBOOT_BASE:#x} changed size')
|
|
data=value
|
|
seen.add(offset+EBOOT_BASE)
|
|
out.append((offset,data))
|
|
missing=set(values)-seen
|
|
if missing: raise ValueError(f'{SCALED_BASE}: no write at {", ".join(hex(a) for a in sorted(missing))}')
|
|
return out
|
|
|
|
|
|
|
|
EFFECT_DATA_RATE=(0x26FB39F,0x4CAD7FC)
|
|
CAMERA_DEFAULTS={0x183a4c3:0x19c,0x183a4cd:0x1a0,0x183a4d7:0x1a4,0x183a4e1:0x1a8,0x183a4f5:0x1b0,
|
|
0x183a4ff:0x1b4,0x183a509:0x1b8,0x183a527:0x1c4,0x183a531:0x1c8,0x183a53b:0x1cc,
|
|
0x183a649:0x238,0x183a653:0x23c,0x183a65d:0x240,0x183a8c1:0x19c,0x183a8cb:0x1a0,
|
|
0x183a8d5:0x1a4,0x183a8df:0x1a8,0x183a8f3:0x1b0,0x183a8fd:0x1b4,0x183a907:0x1b8,
|
|
0x183a925:0x1c4,0x183a92f:0x1c8,0x183a939:0x1cc,0x183aa47:0x238,0x183aa51:0x23c,
|
|
0x183aa5b:0x240}
|
|
CAMERA_PARAMETER_BLOCKS=((0x183FF29,0x183FF70),(0x1840799,0x18407EB))
|
|
CAMERA_FIXED_STORE=bytes.fromhex('41c785')
|
|
CAMERA_PITCH_ANGLE,CAMERA_PITCH_KEPT=0x238,0x1a8
|
|
CAMERA_GAME_EASES={0x4D25E68:0.1,0x4D25E94:1/30}
|
|
CAMERA_LOCK_BLEND=(0x183A7DE,bytes.fromhex('cdcc4c3d'))
|
|
STATUS_STEP_FIRST=0x1CBC4FB
|
|
STATUS_STEP_SECOND=(0x1CBC511,bytes.fromhex('37b70603'),bytes.fromhex('f3e3a903'))
|
|
|
|
|
|
def per_frame(weight, frames, field=None):
|
|
"""A weight tuned for one frame, for a frame `frames` times as long: the share moved, 1-(1-w)^n,
|
|
or for the pitch return the share kept, w^n. The pitch angle and values outside (0, 1) stay."""
|
|
if field==CAMERA_PITCH_ANGLE or not 0<weight<1: return weight
|
|
if field==CAMERA_PITCH_KEPT: return weight**frames
|
|
return 1-(1-weight)**frames
|
|
|
|
|
|
def follow_camera_write(at, data, fps):
|
|
"""A list's camera write with its weights converted to a frame of 1/fps: the 60 list's, the
|
|
game's own from 30."""
|
|
if at in CAMERA_DEFAULTS:
|
|
return struct.pack('<f',per_frame(struct.unpack('<f',data)[0],60/fps,CAMERA_DEFAULTS[at]))
|
|
if len(data)==11 and data[:3]==CAMERA_FIXED_STORE and any(lo<=at<hi for lo,hi in CAMERA_PARAMETER_BLOCKS):
|
|
field,weight=struct.unpack('<If',data[3:])
|
|
return data[:7]+struct.pack('<f',per_frame(weight,60/fps,field))
|
|
if at in CAMERA_GAME_EASES:
|
|
return struct.pack('<f',per_frame(CAMERA_GAME_EASES[at],30/fps))
|
|
return data
|
|
|
|
|
|
CAMERA_JUMP,CAMERA_WALL_ESCAPE=0x183DC96,0x183DCFE
|
|
|
|
|
|
def camera_retune(at, size):
|
|
"""A write of the FPS++ follow camera's retune, not of the game's own camera."""
|
|
return (at in CAMERA_DEFAULTS or at in (CAMERA_JUMP,CAMERA_WALL_ESCAPE) or
|
|
any(at<hi and at+size>lo for lo,hi in CAMERA_PARAMETER_BLOCKS))
|
|
|
|
|
|
def corrected_frame_rate(writes, fps, elf=None, game_camera=False):
|
|
"""The frame-rate preset's writes with the corrections above, for `fps` (None: uncapped, whose
|
|
rate is unknown here: only the effects' rate and the status effects are put right). `elf`, when
|
|
given, must have the game's bytes where a correction adds a write. `game_camera`: the follow
|
|
camera as the game has it, its eases converted from 30 FPS, without FPS++'s retune."""
|
|
camera=bool(fps) and not game_camera and os.environ.get('BB_CAMERA_FIX','0')=='1'
|
|
game_camera=game_camera and bool(fps)
|
|
out=[]
|
|
for offset,data in writes:
|
|
at=offset+EBOOT_BASE
|
|
if at in EFFECT_DATA_RATE: continue
|
|
if game_camera:
|
|
if camera_retune(at,len(data)): continue
|
|
if at in CAMERA_GAME_EASES:
|
|
out.append((offset,struct.pack('<f',per_frame(CAMERA_GAME_EASES[at],30/fps))))
|
|
continue
|
|
if camera: data=follow_camera_write(at,data,fps)
|
|
out.append((offset,data))
|
|
unchanged=lambda at,was: elf is None or original_bytes(elf,at-EBOOT_BASE,len(was))==was
|
|
at,was,now=STATUS_STEP_SECOND
|
|
if any(offset+EBOOT_BASE==STATUS_STEP_FIRST for offset,_ in out) and unchanged(at,was):
|
|
out.append((at-EBOOT_BASE,now))
|
|
at,was=CAMERA_LOCK_BLEND
|
|
if camera and unchanged(at,was):
|
|
out.append((at-EBOOT_BASE,struct.pack('<f',per_frame(struct.unpack('<f',was)[0],30/fps))))
|
|
return out
|
|
|
|
|
|
OUTPUT_SIZE=(1920,1080)
|
|
PRESET_SCALES=[1.0,1.5,1.7,2.0,3.0]
|
|
PRESET_CUSTOM=len(PRESET_SCALES)
|
|
|
|
|
|
def preset_scale(settings):
|
|
preset=int(settings.get('preset','0') or 0)
|
|
if preset==PRESET_CUSTOM:
|
|
try:
|
|
return min(3.0,max(1.0,float(settings.get('render_scale','1.3') or 1.3)))
|
|
except ValueError:
|
|
return 1.3
|
|
return PRESET_SCALES[max(0,min(preset,len(PRESET_SCALES)-1))]
|
|
RESOLUTION_TEMPLATE='Resolution Patch 1280x720 (16:9)'
|
|
EFFECTS={
|
|
'effect_chromatic_aberration':('Disable Chromatic Aberration',None),
|
|
'effect_dof':('Disable DoF',None),
|
|
'effect_motion_blur':('Disable Motion Blur (perf increase)',None),
|
|
'effect_ssao':('Disable SSAO',None),
|
|
'effect_game_aa':('Disable AA',None),
|
|
'effect_dynamic_shadows':('Disable Dynamic Light Shadows (perf increase)',None),
|
|
'effect_ssr':(None,'Enable Screen Space Reflections (READ NOTE)'),
|
|
'skip_intro':(None,'Skip Intro'),
|
|
'debug_camera':(None,'Restore Debug Camera'),
|
|
'debug_menu':(None,'Restore Debug Menu (READ NOTES)'),
|
|
}
|
|
INTEL_TONEMAP='Intel Black Tonemap Fix'
|
|
SFX_WORKAROUND='Intel 12th Gen+ SFX workaround'
|
|
HEAP_PATCH='Increased Graphics Heap Sizes'
|
|
RENDER_POOL_SITES=(0x2195c09,0x2195c46)
|
|
RENDER_POOL_CODE=bytes.fromhex('ba00000400')
|
|
RENDER_POOL_SIZE=0x100000
|
|
GX_REGION_SITE=0x1d9933d
|
|
GX_REGION_CODE=bytes.fromhex('c78388000000')+struct.pack('<I',0x8000000)
|
|
GX_REGION_GAME,GX_REGION_DEFAULT,GX_REGION_MAX=160,288,416
|
|
GX_HEAP_SITE=0x4736dd8
|
|
GX_HEAP_PS4=0x8ef00000
|
|
|
|
|
|
def intel_cpu(cpuinfo='/proc/cpuinfo'):
|
|
try:
|
|
with open(cpuinfo) as f:
|
|
return any(line.startswith('vendor_id') and 'GenuineIntel' in line for line in f)
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def intel_tonemap_fix(env=os.environ, cpuinfo='/proc/cpuinfo'):
|
|
forced=env.get('BB_INTEL_TONEMAP_FIX')
|
|
return forced=='1' if forced in ('0','1') else intel_cpu(cpuinfo)
|
|
|
|
MODEL_LOD={'-2':'Model LOD -2 (Highest)','1':'Model LOD 1 (Lower)','2':'Model LOD 2 (Lowest)'}
|
|
|
|
|
|
def validate_patch_requirements(names, game):
|
|
if 'Restore Debug Camera' in names and 'Enemy Control' in names:
|
|
raise ValueError('Restore Debug Camera conflicts with Enemy Control; enable only one')
|
|
if 'Restore Debug Menu (READ NOTES)' in names:
|
|
font = game / 'dvdroot_ps4/font'
|
|
missing = [name for name in ('DbgFont14h.ccm', 'DbgFont14h.tpf')
|
|
if not (font / name).is_file() or (font / name).stat().st_size == 0]
|
|
if missing:
|
|
unpacked = (font / 'DbgFont14h-tpf-dcx').is_dir() or (font / 'DbgFont14h-tpf').is_dir()
|
|
raise ValueError('Debug menu needs the font files '
|
|
f'{", ".join(missing)} in dvdroot_ps4/font, in the game folder or in an '
|
|
'enabled mod (Mods page).'
|
|
+ (' The installed mod has the font unpacked (a DbgFont14h-tpf-dcx folder): '
|
|
'repack it with WitchyBND, or use the packed files' if unpacked else '')
|
|
+ '. The fonts: https://www.nexusmods.com/bloodborne/mods/253; or turn the '
|
|
'debug menu off (debug_menu in lantern.ini)')
|
|
|
|
|
|
DEFAULT_OFF=('effect_chromatic_aberration','effect_dof','effect_motion_blur','effect_game_aa')
|
|
def effect_patches(settings):
|
|
names=[]
|
|
for key,(off,on) in EFFECTS.items():
|
|
if key not in settings:
|
|
if key in DEFAULT_OFF and off: names.append(off)
|
|
continue
|
|
name=on if settings[key]=='1' else off
|
|
if name: names.append(name)
|
|
lod=MODEL_LOD.get(settings.get('model_lod','0'))
|
|
if lod: names.append(lod)
|
|
return names
|
|
SCENE_WIDTH=0x02196A6B-EBOOT_BASE
|
|
SCENE_HEIGHT=0x02196A7A-EBOOT_BASE
|
|
UI_WIDTH=0x02358554-EBOOT_BASE
|
|
UI_HEIGHT=0x0235855D-EBOOT_BASE
|
|
|
|
|
|
def read_settings(path):
|
|
settings={}
|
|
if path.exists():
|
|
for line in path.read_text().splitlines():
|
|
key,sep,value=line.partition('=')
|
|
if sep and not line.startswith('#'): settings[key.strip()]=value.strip()
|
|
return settings
|
|
|
|
|
|
def render_size(settings,override=''):
|
|
"""Render resolution for the upscaler preset, or None for native."""
|
|
if override:
|
|
w,h=(int(v) for v in override.lower().split('x'))
|
|
return (w,h)
|
|
if settings.get('upscaler','fsr3')=='off': return None
|
|
scale=preset_scale(settings)
|
|
if scale==1.0: return None
|
|
return tuple(max(2,round(v/scale/2)*2) for v in OUTPUT_SIZE)
|
|
|
|
|
|
def output_size(settings):
|
|
"""Output (UI) size from lantern.ini output_res, e.g. 3840x2160; "auto" is the display's own
|
|
(BB_DISPLAY_SIZE, from run_windows.py); 1920x1080 by default."""
|
|
value=settings.get('output_res','').lower()
|
|
if value=='auto': value=os.environ.get('BB_DISPLAY_SIZE','')
|
|
try:
|
|
w,h=(int(v) for v in value.split('x'))
|
|
if w>0 and h>0: return (w,h)
|
|
except ValueError:
|
|
pass
|
|
return OUTPUT_SIZE
|
|
|
|
|
|
def scaled_sizes(settings):
|
|
"""(render, output) for an output other than 1080p (above it, or 720p for the Steam Deck):
|
|
the game renders at output / preset scale (or at the output size without upscaler) and the
|
|
upscaler fills the output. None at 1080p and for TAA (native, live host targets only)."""
|
|
out=output_size(settings)
|
|
if out==OUTPUT_SIZE or settings.get('upscaler')=='taa': return None
|
|
scale=1.0
|
|
if settings.get('upscaler','fsr3')!='off':
|
|
scale=preset_scale(settings)
|
|
scale=max(1.0,min(scale,out[0]/640,out[1]/360))
|
|
render=tuple(max(4,round(v/scale/4)*4) for v in out)
|
|
if render==OUTPUT_SIZE: render=(1916,1076)
|
|
return render,out
|
|
|
|
|
|
def ui_size(settings, out):
|
|
"""The game's UI movie viewport for an output of `out`: 1920x1080, or with Widescreen on
|
|
(lantern.ini widescreen, on by default) a screen of another shape, that shape around it -
|
|
2520x1080 at 21:9, 3840x1080 at 32:9, 1920x1200 at 16:10. Scaleform shows its 1920x1080
|
|
stage whole and centred in it (the HUD stays 16:9 in the middle), and what the
|
|
game places past the stage - enemy gauges, co-op names, the lock-on marker at the screen's
|
|
sides (engine/widescreen.cpp) - is drawn there instead of being cut off at the 16:9 box."""
|
|
w,h=out
|
|
if settings.get('widescreen','1')=='0' or w*OUTPUT_SIZE[1]==h*OUTPUT_SIZE[0]:
|
|
ui=OUTPUT_SIZE
|
|
elif w*OUTPUT_SIZE[1]>h*OUTPUT_SIZE[0]:
|
|
ui=(min(8192,round(OUTPUT_SIZE[1]*w/h/2)*2),OUTPUT_SIZE[1])
|
|
else:
|
|
ui=(OUTPUT_SIZE[0],min(8192,round(OUTPUT_SIZE[0]*h/w/2)*2))
|
|
if os.environ.get('BB_SHARP_UI')=='1':
|
|
k=min(w/ui[0],h/ui[1])
|
|
if k>1: ui=(min(8192,round(ui[0]*k/2)*2),min(8192,round(ui[1]*k/2)*2))
|
|
return ui
|
|
|
|
|
|
def resolution_writes(xml,size,app_version,segments,ui=OUTPUT_SIZE):
|
|
writes=compile_patches(xml,[RESOLUTION_TEMPLATE],app_version,segments)
|
|
replacements={SCENE_WIDTH:(0xB8,0x500,size[0]),
|
|
SCENE_HEIGHT:(0xB8,0x2D0,size[1]),
|
|
UI_WIDTH:(0xB8,0x500,ui[0]),
|
|
UI_HEIGHT:(0xB9,0x2D0,ui[1])}
|
|
out=[]
|
|
seen=set()
|
|
for offset,data in writes:
|
|
if offset in replacements:
|
|
opcode,old,value=replacements[offset]
|
|
if data!=bytes([opcode])+old.to_bytes(3,'little') or offset in seen:
|
|
raise ValueError(f'unexpected resolution patch at {offset+EBOOT_BASE:#x}')
|
|
data=bytes([opcode])+value.to_bytes(3,'little')
|
|
seen.add(offset)
|
|
out.append((offset,data))
|
|
if seen!=replacements.keys():
|
|
raise ValueError('resolution patch is missing scene/UI viewport instructions')
|
|
return out
|
|
|
|
|
|
def eboot_segments(elf):
|
|
phoff,=struct.unpack_from('<Q',elf,0x20)
|
|
phentsize,phnum=struct.unpack_from('<HH',elf,0x36)
|
|
segments=[]
|
|
for i in range(phnum):
|
|
kind,_,_,vaddr,_,_,memsz,_=struct.unpack_from('<IIQQQQQQ',elf,phoff+i*phentsize)
|
|
if kind==1: segments.append((vaddr,vaddr+memsz))
|
|
return segments
|
|
|
|
|
|
def encode(line):
|
|
kind,value=line.get('Type'),line.get('Value')
|
|
if kind=='bytes': return bytes.fromhex(value.replace(' ',''))
|
|
if kind in ('bytes16','bytes32','bytes64'):
|
|
return int(value,0).to_bytes(int(kind[5:])//8,'little')
|
|
if kind=='float32': return struct.pack('<f',float(value))
|
|
if kind=='float64': return struct.pack('<d',float(value))
|
|
if kind=='utf8': return value.encode()+b'\0'
|
|
if kind=='utf16': return value.encode('utf-16-le')+b'\0\0'
|
|
raise ValueError(f'unsupported patch type {kind!r}')
|
|
|
|
|
|
def compile_patches(xml, names, app_version, segments):
|
|
found={}
|
|
for meta in ET.parse(xml).getroot().iter('Metadata'):
|
|
if meta.get('Name') in names and meta.get('AppVer')==app_version and meta.get('AppElf','eboot.bin')=='eboot.bin':
|
|
found[meta.get('Name')]=meta
|
|
missing=[n for n in names if n not in found]
|
|
if missing: raise ValueError(f'patches not found for app version {app_version}: {missing}')
|
|
writes=[]
|
|
for name in names:
|
|
for line in found[name].iter('Line'):
|
|
offset=int(line.get('Address'),0)-EBOOT_BASE
|
|
data=encode(line)
|
|
if not any(start<=offset and offset+len(data)<=end for start,end in segments):
|
|
raise ValueError(f'{name}: address {line.get("Address")} is outside the eboot')
|
|
writes.append((offset,data))
|
|
return writes
|
|
|
|
|
|
def site_table_path(xml):
|
|
return Path(xml).with_name('Bloodborne-1.09-sites.json')
|
|
|
|
|
|
def original_bytes(elf, offset, length):
|
|
"""The executable's bytes at image offset `offset` (zero past a segment's file data)."""
|
|
phoff,=struct.unpack_from('<Q',elf,0x20)
|
|
phentsize,phnum=struct.unpack_from('<HH',elf,0x36)
|
|
for i in range(phnum):
|
|
kind,_,file_offset,vaddr,_,filesz,memsz,_=struct.unpack_from('<IIQQQQQQ',elf,phoff+i*phentsize)
|
|
if kind==1 and vaddr<=offset and offset+length<=vaddr+memsz:
|
|
start=offset-vaddr
|
|
data=elf[file_offset+start:file_offset+min(filesz,start+length)] if start<filesz else b''
|
|
return bytes(data)+bytes(length-len(data))
|
|
return None
|
|
|
|
|
|
def site_crcs(elf, writes):
|
|
return [zlib.crc32(original_bytes(elf,offset,len(data)) or b'') for offset,data in writes]
|
|
|
|
|
|
def differing_sites(elf, writes, expected):
|
|
"""Image offsets of the writes whose original bytes aren't the standard 1.09's."""
|
|
if len(expected)!=len(writes): return [offset for offset,_ in writes]
|
|
return [offset for (offset,_),now,crc in zip(writes,site_crcs(elf,writes),expected) if now!=crc]
|
|
|
|
|
|
def checked_patches(xml, names, app_version, segments, elf, group=()):
|
|
"""`names` without the patches whose sites differ from the standard 1.09; one of `group` (the
|
|
FPS preset's patches, which go together) differing leaves out all of it."""
|
|
try:
|
|
table=json.loads(site_table_path(xml).read_text(encoding='utf-8'))
|
|
except (OSError, ValueError):
|
|
return names
|
|
bad={}
|
|
for name in names:
|
|
if name in table:
|
|
diff=differing_sites(elf,compile_patches(xml,[name],app_version,segments),table[name])
|
|
if diff: bad[name]=diff
|
|
if any(name in bad for name in group):
|
|
for name in group:
|
|
if name in names: bad.setdefault(name,[])
|
|
for name,diff in bad.items():
|
|
where=(f'at {len(diff)} of its {len(table[name])} spots (first: image offset {diff[0]:#x})' if diff
|
|
else 'with the FPS patch above')
|
|
print(f'Patches: "{name}" left out: this game executable is not the standard 1.09 {where}', flush=True)
|
|
return [name for name in names if name not in bad]
|
|
|
|
|
|
def render_pool_writes(elf):
|
|
"""The writes that enlarge the render pools, when the executable has the standard code there."""
|
|
if os.environ.get('BB_RENDER_POOLS')=='0':
|
|
print('Patches: render pools at the game\'s 256 KiB (BB_RENDER_POOLS=0)',flush=True)
|
|
return []
|
|
if any(original_bytes(elf,site,len(RENDER_POOL_CODE))!=RENDER_POOL_CODE for site in RENDER_POOL_SITES):
|
|
print('Patches: render pools left at the game\'s size: this executable differs there',flush=True)
|
|
return []
|
|
return [(site+1,struct.pack('<I',RENDER_POOL_SIZE)) for site in RENDER_POOL_SITES]
|
|
|
|
|
|
def gx_region_writes(elf, heaps_written, env=os.environ):
|
|
"""The writes that enlarge the GX range allocator (and, unless another write already sizes the
|
|
graphics heaps, GFX_GraphicsPrivate by as much), when the executable has the standard bytes."""
|
|
value=env.get('BB_GX_REGION_MB','').strip()
|
|
mib=int(value) if value.isdigit() else GX_REGION_DEFAULT
|
|
if mib<=GX_REGION_GAME:
|
|
print(f'Patches: the GX range allocator at the game\'s {GX_REGION_GAME} MiB (BB_GX_REGION_MB)',flush=True)
|
|
return []
|
|
mib=min(mib,GX_REGION_MAX)
|
|
if original_bytes(elf,GX_REGION_SITE,len(GX_REGION_CODE))!=GX_REGION_CODE or \
|
|
(not heaps_written and original_bytes(elf,GX_HEAP_SITE,8)!=struct.pack('<Q',GX_HEAP_PS4)):
|
|
print('Patches: the GX range allocator left at the game\'s size: this executable differs there',flush=True)
|
|
return []
|
|
out=[(GX_REGION_SITE+6,struct.pack('<I',(mib-GX_REGION_GAME+128)<<20))]
|
|
if not heaps_written:
|
|
out.append((GX_HEAP_SITE,struct.pack('<Q',GX_HEAP_PS4+((mib-GX_REGION_GAME)<<20))))
|
|
print(f'Patches: the GX range allocator {mib} MiB (the game\'s {GX_REGION_GAME})'+
|
|
('' if heaps_written else f', the graphics heap {(GX_HEAP_PS4>>20)+mib-GX_REGION_GAME} MiB'),flush=True)
|
|
return out
|
|
|
|
|
|
|
|
EDITION_CALL=(0x1fb6d20,bytes.fromhex('e8bb210000'),bytes.fromhex('b801000000'))
|
|
|
|
|
|
def old_hunters_writes(elf):
|
|
"""The edition write when the DLC switch is on and the executable has 1.09's call there."""
|
|
if os.environ.get('BB_DLC')!='1':
|
|
return []
|
|
offset,call,edition_one=EDITION_CALL
|
|
if original_bytes(elf,offset,len(call))!=call:
|
|
print('Patches: The Old Hunters edition left alone: this executable differs there',flush=True)
|
|
return []
|
|
return [(offset,edition_one)]
|
|
|
|
|
|
def write_site_table(xml, app_version, elf_path):
|
|
"""The site table from a verified 1.09 eboot.elf (every eboot patch of `app_version`)."""
|
|
from engine_assets import EBOOT_SHA, sha
|
|
elf=Path(elf_path).read_bytes()
|
|
if sha(elf) not in EBOOT_SHA: raise ValueError(f'{elf_path} is not the verified 1.09 executable')
|
|
segments=eboot_segments(elf)
|
|
table={}
|
|
for meta in ET.parse(xml).getroot().iter('Metadata'):
|
|
name=meta.get('Name')
|
|
if meta.get('AppVer')!=app_version or meta.get('AppElf','eboot.bin')!='eboot.bin' or name in table: continue
|
|
try:
|
|
table[name]=site_crcs(elf,compile_patches(xml,[name],app_version,segments))
|
|
except ValueError:
|
|
continue
|
|
site_table_path(xml).write_text(json.dumps(table,indent=0,sort_keys=True)+'\n',encoding='utf-8')
|
|
return table
|
|
|
|
|
|
BLOODBORNE_IDS={'CUSA00207','CUSA00208','CUSA00299','CUSA00900','CUSA01363','CUSA03014',
|
|
'CUSA03023','CUSA03173','CUSA03179'}
|
|
|
|
|
|
def same_app_version(value, app_version):
|
|
"""'1.09' is '01.09' (patch files write either)."""
|
|
try:
|
|
return value is not None and [int(p) for p in value.strip().split('.')]==[int(p) for p in app_version.split('.')]
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def toml_value(raw, number):
|
|
if len(raw)>=2 and raw[0]=='"':
|
|
close=raw.find('"',1)
|
|
if close<0: raise ValueError(f'line {number}: unclosed string')
|
|
rest=raw[close+1:].strip()
|
|
if rest and rest[0]!='#': raise ValueError(f'line {number}: text after the value')
|
|
return raw[1:close]
|
|
value=raw.split('#',1)[0].strip()
|
|
if not value or not value.isdigit(): raise ValueError(f'line {number}: only strings and whole numbers are read')
|
|
return value
|
|
|
|
|
|
def toml_hex(value, number):
|
|
text=''.join(value.split())
|
|
if not text or len(text)%2: raise ValueError(f'line {number}: not whole bytes')
|
|
try:
|
|
bytes.fromhex(text)
|
|
except ValueError:
|
|
raise ValueError(f'line {number}: not hexadecimal') from None
|
|
return text
|
|
|
|
|
|
def toml_patch_as_xml(text, fallback_name):
|
|
"""A .toml patch file as the XML the patches list reads: [patch] with name, description, author,
|
|
order and group, and [site.*] with address, bytes and expect."""
|
|
fields={'name':fallback_name,'description':'','author':'','order':'0','group':''}
|
|
sites=[]
|
|
section=None
|
|
for number,line in enumerate(text.split('\n'),1):
|
|
line=line.strip()
|
|
if not line or line[0]=='#': continue
|
|
if line[0]=='[':
|
|
section='patch' if line=='[patch]' else 'site' if line.startswith('[site.') and line[-1]==']' else None
|
|
if section is None: raise ValueError(f'line {number}: unknown section')
|
|
if section=='site': sites.append({})
|
|
continue
|
|
key,equals,raw=line.partition('=')
|
|
if not equals: raise ValueError(f'line {number}: expected key = value')
|
|
key,value=key.strip(),toml_value(raw.strip(),number)
|
|
if section=='patch':
|
|
if key in fields: fields[key]=value
|
|
elif section=='site':
|
|
if key=='address': sites[-1]['address']=value
|
|
elif key in ('expect','bytes'): sites[-1][key]=toml_hex(value,number)
|
|
else:
|
|
raise ValueError(f'line {number}: a value outside any section')
|
|
if not sites: raise ValueError('no patch sites')
|
|
root=ET.Element('Patch')
|
|
meta=ET.SubElement(root,'Metadata',{'Name':fields['name'],'Note':fields['description'],'Author':fields['author'],
|
|
'AppVer':'01.09','AppElf':'eboot.bin','isEnabled':'false',
|
|
'Order':fields['order'],'Group':fields['group']})
|
|
listing=ET.SubElement(meta,'PatchList')
|
|
for site in sites:
|
|
if not site.get('address') or not site.get('bytes'): raise ValueError('a site without an address or bytes')
|
|
if site.get('expect') and len(site['expect'])!=len(site['bytes']):
|
|
raise ValueError(f'a site at {site["address"]} expects a different length than it writes')
|
|
line={'Type':'bytes','Address':site['address'],'Value':site['bytes']}
|
|
if site.get('expect'): line['Expect']=site['expect']
|
|
ET.SubElement(listing,'Line',line)
|
|
return root
|
|
|
|
|
|
def external_patches(directory, app_version='01.09', exclude=Path(__file__).resolve().parent.parent/'patches/Bloodborne.xml'):
|
|
"""[(key, file, metadata)] of eboot patches for this version in directory/*.xml.
|
|
key is "<file name>/<patch name>" (the launcher's selection, patches.json)."""
|
|
found=[]
|
|
directory=Path(directory)
|
|
for path in sorted([*directory.glob('*.xml'),*directory.glob('*.toml')]) if directory.is_dir() else []:
|
|
if path.resolve()==Path(exclude).resolve() or path.name.lower()==Path(exclude).name.lower(): continue
|
|
try:
|
|
root=(toml_patch_as_xml(path.read_text(encoding='utf-8-sig'),path.stem) if path.suffix.lower()=='.toml'
|
|
else ET.parse(path).getroot())
|
|
except (ET.ParseError,ValueError,OSError) as error:
|
|
print(f'Patches: {path.name}: {error}',file=sys.stderr)
|
|
continue
|
|
ids={e.text.strip() for e in root.iter('ID') if e.text}
|
|
if ids and not ids&BLOODBORNE_IDS: continue
|
|
for meta in root.iter('Metadata'):
|
|
if same_app_version(meta.get('AppVer'),app_version) and meta.get('AppElf','eboot.bin')=='eboot.bin':
|
|
found.append((f'{path.name}/{meta.get("Name")}',path,meta))
|
|
return found
|
|
|
|
|
|
def managed_builtin(name):
|
|
return (name in {n for names in FPS_PRESETS.values() for n in names} or name in (SCALED_BASE, '30 FPS++',
|
|
'60FPS (no deltatime)', INTEL_TONEMAP, SFX_WORKAROUND) or
|
|
name in {n for pair in EFFECTS.values() for n in pair if n} or name in MODEL_LOD.values() or
|
|
name.startswith('Resolution Patch'))
|
|
|
|
|
|
def builtin_selection(xml, config):
|
|
"""The built-in patches switched on in the launcher's Mods page: patches.json keys
|
|
"Bloodborne.xml/<name>" in "enabled" (built-in ones start off, whatever the file says)."""
|
|
try:
|
|
enabled=json.loads(Path(config).read_text()).get('enabled',[]) if config and Path(config).is_file() else []
|
|
except (OSError, ValueError):
|
|
return []
|
|
prefix=Path(xml).name+'/'
|
|
known={meta.get('Name') for meta in ET.parse(xml).getroot().iter('Metadata')}
|
|
return [k[len(prefix):] for k in enabled if k.startswith(prefix) and k[len(prefix):] in known
|
|
and not managed_builtin(k[len(prefix):])]
|
|
|
|
|
|
def placeable(xml, name, app_version, segments):
|
|
"""Whether a built-in patch switched on in the Mods page fits this eboot. One that doesn't
|
|
(Performance Patch writes past its end: GitHub issues 28, 29) is left out, as an external
|
|
patch would be, instead of stopping the launch."""
|
|
try:
|
|
compile_patches(xml,[name],app_version,segments)
|
|
return True
|
|
except ValueError as error:
|
|
print(f'Patches: skipped {Path(xml).name}/{name}: {str(error).removeprefix(name+": ")}',file=sys.stderr)
|
|
return False
|
|
|
|
|
|
def external_selection(found, config):
|
|
"""Selected external patches: patches.json {"enabled": [...], "disabled": [...]} overrides
|
|
each file's isEnabled."""
|
|
settings={}
|
|
if config and Path(config).is_file():
|
|
settings=json.loads(Path(config).read_text())
|
|
enabled,disabled=set(settings.get('enabled',[])),set(settings.get('disabled',[]))
|
|
chosen=[(key,path,meta) for key,path,meta in found
|
|
if key in enabled or (key not in disabled and meta.get('isEnabled','false').lower()=='true')]
|
|
chosen.sort(key=lambda entry: int(entry[2].get('Order','0') or 0) if (entry[2].get('Order','0') or '0').isdigit() else 0)
|
|
kept,groups=[],set()
|
|
for entry in chosen:
|
|
group=entry[2].get('Group','')
|
|
if group and group in groups:
|
|
print(f'Patches: skipped {entry[0]}: another patch of its group {group} is on',file=sys.stderr)
|
|
continue
|
|
groups.add(group)
|
|
kept.append(entry)
|
|
return kept
|
|
|
|
|
|
REGION_FUNCTION=(0x1fb9190,0x1fb91e3)
|
|
|
|
|
|
def breaks_online(writes):
|
|
"""Whether online is on and these writes change the game's region, unless the player chose
|
|
to keep such a patch (the launcher asks at Play: BB_REGION_PATCH_ONLINE=keep)."""
|
|
start,end=REGION_FUNCTION
|
|
return (os.environ.get('BB_ONLINE')=='1' and os.environ.get('BB_REGION_PATCH_ONLINE')!='keep' and
|
|
any(offset<end and start<offset+len(data) for offset,data in writes))
|
|
|
|
|
|
def overlaps(writes, footprint):
|
|
return any(offset+i in footprint for offset,data in writes for i in range(len(data)))
|
|
|
|
|
|
def resolution_footprint(xml, app_version, segments):
|
|
"""The bytes the game's resolution and UI layout live at: every community "Resolution Patch"
|
|
writes them (RESOLUTION_TEMPLATE's sites)."""
|
|
return {offset+i for offset,data in compile_patches(xml,[RESOLUTION_TEMPLATE],app_version,segments)
|
|
for i in range(len(data))}
|
|
|
|
|
|
def skip_for_online(key):
|
|
print(f'Patches: skipped {key} while online is on: it moves the game to a region with no Bloodborne '
|
|
'server (Play online would fail); it applies again with online off',file=sys.stderr)
|
|
|
|
|
|
def compile_external(selected, segments, resolution=None, elf=None):
|
|
"""Writes of the selected external patches; a patch with unsupported lines is skipped whole.
|
|
`resolution`: (footprint, output size) while the output resolution sets the game's resolution
|
|
and UI itself: a patch that writes them too is left out, unless the player kept it (the
|
|
launcher asks at Play: BB_RESOLUTION_PATCH=keep). Applied after ours, it won: the picture
|
|
kept the patch's shape with black bars, and the widescreen hooks found its bytes and refused."""
|
|
writes=[]
|
|
for key,_,meta in selected:
|
|
try:
|
|
ours=[]
|
|
for line in meta.iter('Line'):
|
|
offset=int(line.get('Address') or '',0)-EBOOT_BASE
|
|
data=encode(line)
|
|
if not any(start<=offset and offset+len(data)<=end for start,end in segments):
|
|
raise ValueError(f'address {line.get("Address")} is outside the eboot')
|
|
if line.get('Expect') and elf is not None:
|
|
wanted=bytes.fromhex(line.get('Expect'))
|
|
original=original_bytes(elf,offset,len(data))
|
|
if (original!=wanted if original is not None else any(wanted)):
|
|
raise ValueError(f'the game\'s code at {line.get("Address")} is not what it expects')
|
|
ours.append((offset,data))
|
|
except ValueError as error:
|
|
print(f'Patches: skipped {key}: {error}',file=sys.stderr)
|
|
continue
|
|
if breaks_online(ours):
|
|
skip_for_online(key)
|
|
continue
|
|
if resolution and os.environ.get('BB_RESOLUTION_PATCH')!='keep' and overlaps(ours,resolution[0]):
|
|
w,h=resolution[1]
|
|
print(f'Patches: skipped {key}: it sets the game\'s resolution and UI layout, which the output '
|
|
f'resolution ({w}x{h}) already does; it applies again with a 1920x1080 output',file=sys.stderr)
|
|
continue
|
|
writes+=ours
|
|
print(f'Patches: external {key} ({len(ours)} writes)')
|
|
return writes
|
|
|
|
|
|
def main():
|
|
p=argparse.ArgumentParser(description=__doc__)
|
|
p.add_argument('--patches-dir',type=Path,help='third-party patch XML files (shadPS4 format)')
|
|
p.add_argument('--patches-config',type=Path,help='patches.json: enabled/disabled external patches')
|
|
p.add_argument('--xml',type=Path,default=Path(__file__).resolve().parent.parent/'patches/Bloodborne.xml')
|
|
p.add_argument('--fps',choices=sorted(FPS_PRESETS)+sorted(SCALED_FPS),default='uncap')
|
|
p.add_argument('--extra',default='',help='additional patch names, separated by ";"')
|
|
p.add_argument('--app-version',default='01.09')
|
|
p.add_argument('--out',type=Path,default=Path(__file__).resolve().parent.parent/'out')
|
|
p.add_argument('--settings',type=Path,default=Path(__file__).resolve().parent.parent/'lantern.ini')
|
|
p.add_argument('--game-dir',type=Path,default=Path(os.environ.get('BB_GAME_DIR','../CUSA03173')))
|
|
p.add_argument('--render-res',default='',help='render resolution WxH (overrides the preset)')
|
|
p.add_argument('--print-preset-size',action='store_true',help='print the selected preset size, if reduced')
|
|
p.add_argument('--output-res',default='',help='output resolution WxH (the upscaler\'s; the UI stays 1920x1080)')
|
|
p.add_argument('--print-scaled',action='store_true',
|
|
help='print "RENDER OUTPUT" (WxH) when lantern.ini selects an output other than 1080p')
|
|
p.add_argument('--write-site-table',type=Path,metavar='EBOOT_ELF',
|
|
help='write the standard 1.09 site table next to the XML from a verified eboot.elf')
|
|
a=p.parse_args()
|
|
if a.write_site_table:
|
|
print(f'{len(write_site_table(a.xml,a.app_version,a.write_site_table))} patches in {site_table_path(a.xml)}')
|
|
return
|
|
if a.print_scaled:
|
|
sizes=scaled_sizes(read_settings(a.settings))
|
|
if sizes: print(f'{sizes[0][0]}x{sizes[0][1]} {sizes[1][0]}x{sizes[1][1]}')
|
|
return
|
|
if a.print_preset_size:
|
|
settings=read_settings(a.settings)
|
|
if 'BB_UPSCALER' in os.environ:
|
|
settings['upscaler']='fsr3' if os.environ['BB_UPSCALER']=='fsr3' else 'off'
|
|
if 'BB_UPSCALE_PRESET' in os.environ:
|
|
settings['preset']=os.environ['BB_UPSCALE_PRESET']
|
|
size=render_size(settings)
|
|
if size: print(f'{size[0]}x{size[1]}')
|
|
return
|
|
preset=FPS_PRESETS[a.fps] if a.fps in FPS_PRESETS else [SCALED_BASE,'Sprint Fix (High FPS)']
|
|
if os.environ.get('LANTERN_FRAMERATE_FROM_ENGINE')=='1':
|
|
print(f"Patches: {a.fps} FPS from the engine's native frame rate (no frame-rate patches)")
|
|
preset=[]
|
|
names=preset+[n.strip() for n in a.extra.split(';') if n.strip()]
|
|
names+=[n for n in effect_patches(read_settings(a.settings)) if n not in names]
|
|
selected=[n for n in builtin_selection(a.xml,a.patches_config) if n not in names]
|
|
names+=selected
|
|
if intel_tonemap_fix() and INTEL_TONEMAP not in names:
|
|
names.append(INTEL_TONEMAP)
|
|
if os.environ.get('BB_SFX_WORKAROUND')=='1' and SFX_WORKAROUND not in names:
|
|
names.append(SFX_WORKAROUND)
|
|
validate_patch_requirements(names,a.game_dir)
|
|
elf=(a.out/'eboot.elf').read_bytes()
|
|
segments=eboot_segments(elf)
|
|
names=[n for n in names if n not in selected or placeable(a.xml,n,a.app_version,segments)]
|
|
names=checked_patches(a.xml,names,a.app_version,segments,elf,preset)
|
|
for name in [n for n in names if breaks_online(compile_patches(a.xml,[n],a.app_version,segments))]:
|
|
skip_for_online(name)
|
|
names.remove(name)
|
|
writes=compile_patches(a.xml,names,a.app_version,segments)
|
|
if a.fps in SCALED_FPS and SCALED_BASE in names:
|
|
writes=rate_scaled(writes,SCALED_FPS[a.fps])
|
|
print(f'Patches: {a.fps} FPS: {SCALED_BASE} with its frame-rate values for {a.fps}')
|
|
if preset and preset[0] in names:
|
|
fps={'60':60,'90':90,**SCALED_FPS}.get(a.fps)
|
|
tuning=os.environ.get('BB_CAMERA_TUNING',read_settings(a.settings).get('camera_tuning','original')).lower()
|
|
writes=corrected_frame_rate(writes,fps,elf,tuning!='fps++')
|
|
if fps:
|
|
print('Patches: follow camera '+('as the game has it, its eases converted from 30 FPS' if tuning!='fps++'
|
|
else 'with the FPS++ retune (camera_tuning=fps++)'))
|
|
writes+=old_hunters_writes(elf)
|
|
pools=render_pool_writes(elf)
|
|
if pools:
|
|
writes+=pools
|
|
print(f'Patches: render pools {RENDER_POOL_SIZE>>10} KiB each (the game\'s 256)')
|
|
size=render_size(read_settings(a.settings),a.render_res) if a.render_res else None
|
|
out=output_size(read_settings(a.settings))
|
|
if a.output_res:
|
|
out=tuple(int(v) for v in a.output_res.lower().split('x'))
|
|
ui=ui_size(read_settings(a.settings),out) if size else OUTPUT_SIZE
|
|
if size:
|
|
writes+=resolution_writes(a.xml,size,a.app_version,segments,ui)
|
|
if size[0]*size[1]>OUTPUT_SIZE[0]*OUTPUT_SIZE[1] and HEAP_PATCH not in names:
|
|
heap=HEAP_PATCH
|
|
writes+=compile_patches(a.xml,[heap],a.app_version,segments)
|
|
names.append(heap)
|
|
print(f'Patches: scene {size[0]}x{size[1]}; UI {ui[0]}x{ui[1]}')
|
|
if a.patches_dir:
|
|
resolution=(resolution_footprint(a.xml,a.app_version,segments),out) if out!=OUTPUT_SIZE else None
|
|
writes+=compile_external(external_selection(external_patches(a.patches_dir,a.app_version,a.xml),
|
|
a.patches_config),segments,resolution,elf)
|
|
heap_bytes={offset+i for offset,data in compile_patches(a.xml,[HEAP_PATCH],a.app_version,segments)
|
|
for i in range(len(data))}
|
|
heaps=any(offset+i in heap_bytes for offset,data in writes for i in range(len(data)))
|
|
writes+=gx_region_writes(elf,any(offset<GX_HEAP_SITE+16 and offset+len(data)>GX_HEAP_SITE
|
|
for offset,data in writes))
|
|
(a.out/'patches-info.json').write_text(json.dumps({'heap_patch':heaps,'ui_size':f'{ui[0]}x{ui[1]}'})+'\n',encoding='utf-8')
|
|
blob=struct.pack('<8sQQ',b'BBPATCH2',EBOOT_BASE,len(writes))
|
|
for offset,data in writes: blob+=struct.pack('<QQ',offset,len(data))+data
|
|
(a.out/'patches.bin').write_bytes(blob)
|
|
print(f'Patches: FPS preset {a.fps}; {len(writes)} writes from {names or "none"}')
|
|
|
|
|
|
if __name__=='__main__':
|
|
main()
|