Ultrawide: widen the view for outputs wider than 16:9, and keep custom sizes

- An output_res wider than 16:9 (5120x2160, 3440x1440, ...) filled the screen
  but kept the game's 16:9 projection, so the picture was stretched. patches.py
  now takes the community resolution patch for that size (Kyo's 21:9 and 32:9
  ones in Bloodborne.xml, or one of the same aspect) and applies only its writes
  that widen the game's screen: not the scene and UI sizes or the heap patch,
  which bbport sets itself. bbport.ini aspect_patch=0 turns it off.
- The in-game menu and the launcher listed four sizes: a custom output_res
  became 3840x2160 with the first saved setting, and 1280x720 in the launcher.
  Both keep it now until another size is chosen; tests/test_settings.cpp
  covers the menu side.

From deadinside28/bloodborne_pc#183.

Thanks: pmwork-personal, in 7 languages.

Co-Authored-By: pmwork-personal <[email protected]>
This commit is contained in:
ayozetrandpmwork-personal committed 2026-10-14 12:14:54 +01:00
1 parent 16a08695c3
commit 8d1db30f9e
7 files changed
+127 -10

No files matched your search

+16 -6
View File
@@ -378,13 +378,14 @@ void Menu() {
for (int i = 0; i < BbSettings::PresetCount; ++i) {
char label[64];
const float scale = BbSettings::PresetScale(i);
const int output = s.output_res;
int output_width = BbSettings::OutputWidths[s.output_res];
int output_height = BbSettings::OutputHeights[s.output_res];
BbSettings::CustomOutput(output_width, output_height);
std::snprintf(
label, sizeof(label),
BbSettings::MenuText("%s (x%.1f, render %dx%d)", "%s (x%.1f, рендер %dx%d)"),
BbSettings::PresetName(i), scale,
int(std::lround(BbSettings::OutputWidths[output] / scale / 2) * 2),
int(std::lround(BbSettings::OutputHeights[output] / scale / 2) * 2));
BbSettings::PresetName(i), scale, int(std::lround(output_width / scale / 2) * 2),
int(std::lround(output_height / scale / 2) * 2));
if (ImGui::Selectable(label, i == preset)) {
Store(s.preset, i, true);
}
@@ -507,10 +508,19 @@ void Menu() {
ImGui::SeparatorText(BbSettings::MenuText("Output resolution", "Разрешение вывода"));
static const char* outputs[] = {"1280 x 720", "1920 x 1080", "2560 x 1440", "3840 x 2160"};
int output = s.output_res;
// A size from bbport.ini that the list does not have stays until another one is chosen.
int custom_width = 0, custom_height = 0;
const bool custom = BbSettings::CustomOutput(custom_width, custom_height);
char custom_label[32];
std::snprintf(custom_label, sizeof(custom_label), "%d x %d", custom_width, custom_height);
if (ImGui::BeginCombo(BbSettings::MenuText("Output resolution", "Разрешение вывода"),
outputs[output])) {
custom ? custom_label : outputs[output])) {
if (custom) {
ImGui::Selectable(custom_label, true);
}
for (int i = 0; i < BbSettings::OutputCount; ++i) {
if (ImGui::Selectable(outputs[i], i == output)) {
if (ImGui::Selectable(outputs[i], !custom && i == output)) {
s.custom_output_width = 0; // a listed size replaces the one from bbport.ini
Store(s.output_res, i, true);
}
}
+28 -2
View File
@@ -86,11 +86,19 @@ void Set(Values& v, const std::string& key, const std::string& value) {
} else if (key == "live_resolution") {
v.live_resolution = value == "auto" ? -1 : std::clamp(i, 0, 1);
} else if (key == "output_res") {
bool listed = false;
for (int r = 0; r < OutputCount; ++r) {
if (value == std::to_string(OutputWidths[r]) + "x" + std::to_string(OutputHeights[r])) {
v.output_res = r;
listed = true;
}
}
int width = 0, height = 0;
if (!listed && std::sscanf(value.c_str(), "%dx%d", &width, &height) == 2 && width > 0 &&
height > 0) {
v.custom_output_width = width;
v.custom_output_height = height;
}
} else {
for (int e = 0; e < EffectCount; ++e) {
if (key == Effects[e].key) {
@@ -136,6 +144,7 @@ MenuLanguage MenuLanguageFromCode(const char* code) {
void Load() {
auto& v = Get();
v.custom_output_width = v.custom_output_height = 0;
for (int e = 0; e < EffectCount; ++e) {
v.effects[e] = Effects[e].default_on;
}
@@ -194,6 +203,7 @@ void Load() {
}
v.startup_model_lod = v.model_lod;
v.startup_output_res = v.output_res;
v.startup_custom_output_width = v.custom_output_width;
v.startup_live_resolution = v.live_resolution;
}
@@ -235,11 +245,22 @@ int RenderPreset() {
v.upscaler == UpscalerTaa ? NativeAA : v.preset.load();
}
bool CustomOutput(int& width, int& height) {
const auto& v = Get();
if (v.custom_output_width <= 0 || v.output_res != v.startup_output_res) {
return false;
}
width = v.custom_output_width;
height = v.custom_output_height;
return true;
}
bool ResolutionNeedsRestart() {
const auto& v = Get();
// TAA needs the live path (native guest targets): run.sh selects it on restart.
return FixedRenderSession() &&
(v.preset != v.startup_preset || v.output_res != v.startup_output_res ||
v.custom_output_width != v.startup_custom_output_width ||
(v.upscaler == UpscalerOff) != (v.startup_upscaler == UpscalerOff) ||
(v.upscaler == UpscalerTaa) != (v.startup_upscaler == UpscalerTaa));
}
@@ -282,8 +303,13 @@ void Save() {
put(Effects[e].key, flag(v.effects[e]));
}
put("model_lod", std::to_string(v.model_lod.load()));
put("output_res", std::to_string(OutputWidths[v.output_res]) + "x" +
std::to_string(OutputHeights[v.output_res]));
int custom_width = 0, custom_height = 0;
if (CustomOutput(custom_width, custom_height)) {
put("output_res", std::to_string(custom_width) + "x" + std::to_string(custom_height));
} else {
put("output_res", std::to_string(OutputWidths[v.output_res]) + "x" +
std::to_string(OutputHeights[v.output_res]));
}
// Read by run.sh at start.
put("live_resolution", v.live_resolution < 0 ? "auto" : flag(v.live_resolution != 0));
if (v.menu_x >= 0.0f && v.menu_y >= 0.0f) {
+6
View File
@@ -97,6 +97,9 @@ struct Values {
std::atomic<bool> effects[EffectCount]{};
std::atomic<int> model_lod{0}; ///< -2 highest .. 2 lowest, 0 the game's
std::atomic<int> output_res{OutputDefault}; ///< index into OutputWidths
/// An output_res in bbport.ini that the list does not have (5120x2160, ...): run.sh and
/// patches.py use it, and the menu keeps it until another size is chosen.
int custom_output_width = 0, custom_output_height = 0;
/// Live resolution and preset changes (run.sh): 0 off by default (startup patch, fastest
/// on the Steam Deck and older GPUs), -1 auto (strong discrete GPUs), 1 on. On restart.
std::atomic<int> live_resolution{0};
@@ -114,6 +117,7 @@ struct Values {
bool startup_effects[EffectCount]{};
int startup_model_lod = 0;
int startup_output_res = OutputDefault;
int startup_custom_output_width = 0;
int startup_live_resolution = 0;
};
@@ -136,6 +140,8 @@ void ConfigureDlssSupport(bool available, const char* problem);
bool FixedRenderSession();
int RenderPreset();
bool ResolutionNeedsRestart();
/// The output size from bbport.ini that the menu does not list, while it is still the choice.
bool CustomOutput(int& width, int& height);
/// Writes the file (menu changes).
void Save();
+7
View File
@@ -600,6 +600,7 @@ EN = {
'Вылет видеоплеера при его остановке': 'A video player crash when it stops',
'Язык внутриигрового меню при запуске без окна лаунчера': "The in-game menu's language when starting without the launcher window",
'DLSS в AppImage на NVIDIA': 'DLSS in the AppImage on NVIDIA',
'Сверхширокие мониторы без растянутой картинки': 'Ultrawide monitors without a stretched picture',
}
PT_BR = {
@@ -1254,6 +1255,7 @@ PT_BR = {
'Вылет видеоплеера при его остановке': 'Um travamento do reprodutor de vídeo ao parar',
'Язык внутриигрового меню при запуске без окна лаунчера': 'O idioma do menu do jogo ao iniciar sem a janela do launcher',
'DLSS в AppImage на NVIDIA': 'DLSS no AppImage em NVIDIA',
'Сверхширокие мониторы без растянутой картинки': 'Monitores ultrawide sem imagem esticada',
}
@@ -1819,6 +1821,7 @@ ES = {
'Вылет видеоплеера при его остановке': 'Un cierre del reproductor de vídeo al detenerse',
'Язык внутриигрового меню при запуске без окна лаунчера': 'El idioma del menú del juego al arrancar sin la ventana del launcher',
'DLSS в AppImage на NVIDIA': 'DLSS en la AppImage con NVIDIA',
'Сверхширокие мониторы без растянутой картинки': 'Monitores ultrapanorámicos sin la imagen estirada',
}
@@ -2384,6 +2387,7 @@ IT = {
'Вылет видеоплеера при его остановке': 'Un crash del lettore video quando si ferma',
'Язык внутриигрового меню при запуске без окна лаунчера': 'La lingua del menu di gioco avviando senza la finestra del launcher',
'DLSS в AppImage на NVIDIA': "DLSS nell'AppImage su NVIDIA",
'Сверхширокие мониторы без растянутой картинки': 'Monitor ultrawide senza immagine stirata',
}
@@ -2949,6 +2953,7 @@ FR = {
'Вылет видеоплеера при его остановке': 'Un plantage du lecteur vidéo à son arrêt',
'Язык внутриигрового меню при запуске без окна лаунчера': 'La langue du menu en jeu au lancement sans la fenêtre du launcher',
'DLSS в AppImage на NVIDIA': 'DLSS dans l’AppImage sur NVIDIA',
'Сверхширокие мониторы без растянутой картинки': 'Les écrans ultra-larges sans image étirée',
}
@@ -3514,6 +3519,7 @@ DE = {
'Вылет видеоплеера при его остановке': 'Ein Absturz des Videoplayers beim Anhalten',
'Язык внутриигрового меню при запуске без окна лаунчера': 'Die Sprache des Spielmenüs beim Start ohne Launcher-Fenster',
'DLSS в AppImage на NVIDIA': 'DLSS im AppImage auf NVIDIA',
'Сверхширокие мониторы без растянутой картинки': 'Ultrawide-Monitore ohne gestrecktes Bild',
}
@@ -4022,6 +4028,7 @@ ZH_CN = {
'Вылет видеоплеера при его остановке': '视频播放器停止时的崩溃',
'Язык внутриигрового меню при запуске без окна лаунчера': '不经启动器窗口启动时游戏内菜单的语言',
'DLSS в AppImage на NVIDIA': 'NVIDIA 上 AppImage 中的 DLSS',
'Сверхширокие мониторы без растянутой картинки': '超宽屏显示器画面不再被拉伸',
}
# Translations by language code; Russian is the key itself. Strings missing in a translation
+6 -1
View File
@@ -631,6 +631,7 @@ THANKS = (
_github("emurphy04", "Вылет видеоплеера при его остановке"),
_github("AyrtonSanabio", "Язык внутриигрового меню при запуске без окна лаунчера"),
_github("Aryxa", "DLSS в AppImage на NVIDIA"),
_github("pmwork-personal", "Сверхширокие мониторы без растянутой картинки"),
)),
("Помощь и тесты", (
_github("willthehuman", "Подробные отчёты с журналами и проверка исправлений"),
@@ -838,9 +839,13 @@ class LauncherWindow(Adw.ApplicationWindow):
page.add(self.patches_group)
screen = Adw.PreferencesGroup(title=tr("Экран"))
# A size written by hand in bbport.ini (5120x2160, ...) stays a choice instead of 720p.
output = self.ini.get("output_res", "1920x1080")
outputs = OUTPUT_RES + ([(output.replace("x", "×"), output)]
if output not in [value for _, value in OUTPUT_RES] else [])
self.output_row = combo_row(tr("Разрешение вывода"),
tr("Апскейлер дорисовывает кадр; Steam Deck — 720p"),
OUTPUT_RES, self.ini.get("output_res", "1920x1080"))
outputs, output)
screen.add(self.output_row)
self.live_row = combo_row(tr("Смена разрешения на лету"),
tr("Без перезапуска, но медленнее на Steam Deck и старых GPU"),
+43
View File
@@ -7,6 +7,7 @@ image places eboot vaddr 0 at image offset 0. Only literal writes are supported
import argparse
import json
import os
import re
import struct
import sys
import xml.etree.ElementTree as ET
@@ -170,6 +171,14 @@ SCENE_WIDTH=0x02196A6B-EBOOT_BASE
SCENE_HEIGHT=0x02196A7A-EBOOT_BASE
UI_WIDTH=0x02358554-EBOOT_BASE
UI_HEIGHT=0x0235855D-EBOOT_BASE
# Outputs wider than 16:9: the community resolution patch for that size (Kyo's "Resolution Patch
# WxH (21:9)" etc. in patches/Bloodborne.xml) also widens the game's logical screen -- the
# projection aspect, the 2D layout width and the lock-on/HP-bar coordinates -- so the view
# gets wider instead of stretched. bbport keeps its own scene/UI sizes (resolution_writes) and
# heap patch, so only that patch's remaining writes are applied. bbport.ini aspect_patch=0
# turns this off.
HEAP_SITES={0x04B36DDA-EBOOT_BASE,0x04B36DE2-EBOOT_BASE}
RESOLUTION_PATCH=re.compile(r'Resolution Patch (\d+)x(\d+) \(')
def read_settings(path):
@@ -242,6 +251,33 @@ def resolution_writes(xml,size,app_version,segments,ui=OUTPUT_SIZE):
return out
def aspect_patch_name(xml,app_version,output):
"""The community resolution patch for an output wider than 16:9: the one for this exact
size, else the closest-width one of the same aspect ratio. None for 16:9 and narrower
outputs, and for an aspect ratio no patch covers (that output is still stretched)."""
w,h=output
if w*9<=h*16: return None
same_aspect=[]
for meta in ET.parse(xml).getroot().iter('Metadata'):
m=RESOLUTION_PATCH.match(meta.get('Name',''))
if not m or meta.get('AppVer')!=app_version: continue
pw,ph=int(m[1]),int(m[2])
if pw*9<=ph*16: continue
if (pw,ph)==(w,h): return meta.get('Name')
if abs(pw/ph-w/h)<0.005: same_aspect.append((abs(pw-w),meta.get('Name')))
return min(same_aspect)[1] if same_aspect else None
def aspect_writes(xml,output,app_version,segments):
"""(patch name, writes) widening the game's view to the output's aspect, or (None, [])."""
name=aspect_patch_name(xml,app_version,output)
if not name: return None,[]
template=dict(compile_patches(xml,[RESOLUTION_TEMPLATE],app_version,segments))
skip={SCENE_WIDTH,SCENE_HEIGHT,UI_WIDTH,UI_HEIGHT}|HEAP_SITES
return name,[(offset,data) for offset,data in compile_patches(xml,[name],app_version,segments)
if offset not in skip and template.get(offset)!=data]
def eboot_segments(elf):
phoff,=struct.unpack_from('<Q',elf,0x20)
phentsize,phnum=struct.unpack_from('<HH',elf,0x36)
@@ -390,6 +426,13 @@ def main():
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]}')
settings=read_settings(a.settings)
output=tuple(int(v) for v in a.output_res.lower().split('x')) if a.output_res else output_size(settings)
if settings.get('aspect_patch','1')!='0':
aspect_name,aspect=aspect_writes(a.xml,output,a.app_version,segments)
if aspect:
writes+=aspect
print(f'Patches: aspect {output[0]}x{output[1]} from {aspect_name!r} ({len(aspect)} writes)')
if a.patches_dir:
# After the built-in ones: an external patch of the same bytes wins.
writes+=compile_external(external_selection(external_patches(a.patches_dir,a.app_version,a.xml),
+21 -1
View File
@@ -66,6 +66,26 @@ int main() {
s.show_fps = false;
BbSettings::Save();
assert(Read(path).find("menu_language=ru\n") != std::string::npos);
// An output size the menu does not list (deadinside28#183) survives a save...
{
FILE* f = std::fopen(path, "a");
std::fputs("output_res=5120x2160\n", f);
std::fclose(f);
}
BbSettings::Load();
int width = 0, height = 0;
assert(BbSettings::CustomOutput(width, height) && width == 5120 && height == 2160);
s.show_fps = true;
BbSettings::Save();
assert(Read(path).find("output_res=5120x2160\n") != std::string::npos);
// ...until a listed one is chosen.
s.custom_output_width = 0;
s.output_res = 2;
BbSettings::Save();
assert(Read(path).find("output_res=2560x1440\n") != std::string::npos);
assert(!BbSettings::CustomOutput(width, height));
unlink(path);
std::puts("PASS: settings save keeps other keys, menu position, only a chosen menu language");
std::puts("PASS: settings save keeps other keys, menu position, only a chosen menu language, "
"a custom output size");
}