mirror of
https://github.com/ayozetr/bloodborne_linux.git
synced 2026-11-11 09:21:47 -10:00
Compare commits
156
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
002f9c170f | ||
|
|
4e2d0824e2 | ||
|
|
f6633ea393 | ||
|
|
a22c380af1 | ||
|
|
80bfb4c5e8 | ||
|
|
28246a9489 | ||
|
|
1a46cf5bcc | ||
|
|
c48221a3f3 | ||
|
|
b7b97895f2 | ||
|
|
21ba4491b9 | ||
|
|
d9599e4663 | ||
|
|
4a45c725ee | ||
|
|
71de6f9945 | ||
|
|
94999afb3d | ||
|
|
a445ff2e5a | ||
|
|
f605ea9be4 | ||
|
|
954d969f50 | ||
|
|
d8ec9ed684 | ||
|
|
efb652ad8a | ||
|
|
fb3f39ba8a | ||
|
|
4140b670bf | ||
|
|
e7cbc43baa | ||
|
|
f502965635 | ||
|
|
8c7f4c53fa | ||
|
|
d79bcb27a6 | ||
|
|
cb3252eb62 | ||
|
|
3f07ea173c | ||
|
|
219be762f8 | ||
|
|
74e061091b | ||
|
|
bd1b901698 | ||
|
|
ab6e5f4162 | ||
|
|
fd253355e5 | ||
|
|
fdd924b5e7 | ||
|
|
a8f913a9c3 | ||
|
|
14ae3076ca | ||
|
|
27d5d3802f | ||
|
|
cc4999adc5 | ||
|
|
9b90958953 | ||
|
|
cc28a796ae | ||
|
|
004c8d40ad | ||
|
|
7191ac0ec7 | ||
|
|
1cc27f4223 | ||
|
|
e107d9e56f | ||
|
|
aedbe8779e | ||
|
|
fab33687cf | ||
|
|
49e6c535bb | ||
|
|
5531775af3 | ||
|
|
597ad6bad9 | ||
|
|
ea13d1c243 | ||
|
|
27f3c6d061 | ||
|
|
ed7dbcf626 | ||
|
|
189c03b25e | ||
|
|
a1ba326fdc | ||
|
|
cc30687384 | ||
|
|
3b826a415e | ||
|
|
084c7f7d6d | ||
|
|
23728115f0 | ||
|
|
eb20620aaf | ||
|
|
037a24df0e | ||
|
|
d637e22258 | ||
|
|
95878adbba | ||
|
|
dac2621116 | ||
|
|
5a88481947 | ||
|
|
636621403f | ||
|
|
35acaa9e59 | ||
|
|
a0148d4930 | ||
|
|
7e7cedd4b0 | ||
|
|
ba79de106f | ||
|
|
8f67573bd3 | ||
|
|
21e0edd40a | ||
|
|
a7e6d44076 | ||
|
|
2b8d9e1fe1 | ||
|
|
1ccef67d2c | ||
|
|
a643904df0 | ||
|
|
a3e31c7227 | ||
|
|
6b12b22376 | ||
|
|
a36201f796 | ||
|
|
3c2033b5d0 | ||
|
|
d5c2611f72 | ||
|
|
73f3bb512a | ||
|
|
5e86d0bde7 | ||
|
|
30b3409674 | ||
|
|
b3c8b1dadd | ||
|
|
697af8936f | ||
|
|
576a214b5f | ||
|
|
8d1db30f9e | ||
|
|
16a08695c3 | ||
|
|
b4d74c88f7 | ||
|
|
bb3a9d126d | ||
|
|
3d2b8b3067 | ||
|
|
186a342750 | ||
|
|
2bcc093bd9 | ||
|
|
8937a721ca | ||
|
|
4f3187843f | ||
|
|
da5561d6c7 | ||
|
|
f9186dbb8d | ||
|
|
1b93552dca | ||
|
|
d7a94e5dfc | ||
|
|
e2b3f73e71 | ||
|
|
1ce06532c2 | ||
|
|
49a37b4459 | ||
|
|
25cdef7482 | ||
|
|
9413c82bfb | ||
|
|
f4a12043f6 | ||
|
|
7e02e06139 | ||
|
|
7135aa9288 | ||
|
|
48de7625b5 | ||
|
|
1ca5a39b3a | ||
|
|
34ca9d1340 | ||
|
|
b18055557b | ||
|
|
ebb79d3774 | ||
|
|
479e964d90 | ||
|
|
fb25a78b5d | ||
|
|
5bf966b85a | ||
|
|
e7f5b9f575 | ||
|
|
51927fb018 | ||
|
|
c5c983c248 | ||
|
|
a80e1ed7d5 | ||
|
|
a07910eb89 | ||
|
|
2e4a55f8bb | ||
|
|
f1d897ea0d | ||
|
|
9779cbbc70 | ||
|
|
e36717374e | ||
|
|
542e9ada19 | ||
|
|
9e629d2efd | ||
|
|
74584d57b5 | ||
|
|
40b67fc784 | ||
|
|
82923859db | ||
|
|
2ea5b61641 | ||
|
|
0ca95492c4 | ||
|
|
4fd797f7e9 | ||
|
|
1ebe35c2e8 | ||
|
|
17e7741934 | ||
|
|
97a60dd497 | ||
|
|
a3a8a3af6e | ||
|
|
f1091cc0b1 | ||
|
|
c496667284 | ||
|
|
a8b7a8eaa1 | ||
|
|
4d934b7529 | ||
|
|
442c2b66bd | ||
|
|
d7a42fde94 | ||
|
|
d0318b1ab5 | ||
|
|
1bc4f37504 | ||
|
|
44a6b7c3ce | ||
|
|
efdf7b176e | ||
|
|
4f7df3de5c | ||
|
|
e3c51c48aa | ||
|
|
33393185b9 | ||
|
|
c294942db4 | ||
|
|
6c6974a742 | ||
|
|
83246710d7 | ||
|
|
b71eb7cfee | ||
|
|
b6c25e1fb4 | ||
|
|
3c57da4198 | ||
|
|
0e2b626358 | ||
|
|
b060b9fa2b |
No files matched your search
@@ -0,0 +1 @@
|
||||
ko_fi: ayozetr
|
||||
@@ -0,0 +1,84 @@
|
||||
name: Bug report
|
||||
description: The game or the launcher does something wrong, crashes or does not start.
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for reporting. Before you start, [docs/TROUBLESHOOTING.md](https://github.com/ayozetr/bloodborne_linux/blob/main/docs/TROUBLESHOOTING.md) covers the common problems.
|
||||
|
||||
Only your own dump of the game with the 1.09 update is supported. Do not ask for or link game files.
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: The launcher's **About** page shows it.
|
||||
placeholder: 0.51-linux.2
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: install
|
||||
attributes:
|
||||
label: How you run it
|
||||
options:
|
||||
- AppImage
|
||||
- From source (run.sh / the launcher in a checkout)
|
||||
- Steam Deck (AppImage)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: system
|
||||
attributes:
|
||||
label: Distribution, GPU and driver
|
||||
placeholder: CachyOS, RTX 3060, NVIDIA 580.95 / Fedora 43, RX 7800 XT, Mesa 25.2
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: graphics
|
||||
attributes:
|
||||
label: Upscaler, preset and output resolution
|
||||
description: From the launcher's **Settings** page or the in-game menu (Insert / L3+R3).
|
||||
placeholder: FSR 3.1, Quality, 2560x1440
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What happened
|
||||
description: What you saw, and what you expected instead. Screenshots or a short video help.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: How to make it happen
|
||||
description: Where in the game, and what you did right before. Say if it happens every time.
|
||||
placeholder: |
|
||||
1. Load the save at the Cathedral Ward lamp
|
||||
2. Walk towards Old Yharnam
|
||||
3. The game closes during the load
|
||||
- type: dropdown
|
||||
id: online
|
||||
attributes:
|
||||
label: Online
|
||||
options:
|
||||
- Offline
|
||||
- Online, shadPS4's public server
|
||||
- Online, my own server
|
||||
- type: textarea
|
||||
id: log
|
||||
attributes:
|
||||
label: Log
|
||||
description: |
|
||||
In the launcher's **Log** page, **Export log…** and drag the file here (or **Copy** and paste it).
|
||||
Do not paste your shadNet password or your trophy key anywhere in the issue.
|
||||
placeholder: Drag the exported log here.
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Checks
|
||||
options:
|
||||
- label: I use my own dump of Bloodborne with the 1.09 update.
|
||||
required: true
|
||||
- label: I searched the existing issues for the same problem.
|
||||
required: true
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Troubleshooting
|
||||
url: https://github.com/ayozetr/bloodborne_linux/blob/main/docs/TROUBLESHOOTING.md
|
||||
about: The common problems and what to do, before opening an issue.
|
||||
- name: Online play and your own server
|
||||
url: https://github.com/ayozetr/bloodborne_linux/blob/main/docs/ONLINE.md
|
||||
about: Accounts, the connection test, summons and the private server guide.
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Feature request
|
||||
description: Something the port or the launcher could do.
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What you would like
|
||||
description: What it should do, and when you would use it.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: why
|
||||
attributes:
|
||||
label: Why
|
||||
description: What it solves for you, or what you do today instead.
|
||||
- type: textarea
|
||||
id: elsewhere
|
||||
attributes:
|
||||
label: Seen elsewhere
|
||||
description: Another port, fork or mod that already has it (a link helps a lot).
|
||||
@@ -24,7 +24,9 @@ directly on the PC:
|
||||
One step remains to the full Wine + DXVK model: make the new mode the default and remove the old
|
||||
memory model (see below).
|
||||
|
||||
> **No game files are included.** You need your own dump of Bloodborne (CUSA03173, v1.09).
|
||||
> **No game files are included.** You need your own dump of Bloodborne with update 1.09: any retail
|
||||
> release (CUSA00900 in the US, CUSA03173 for the European Game of the Year edition, ...), as they
|
||||
> share the same executable.
|
||||
> This project is not affiliated with Sony Interactive Entertainment, FromSoftware or AMD.
|
||||
|
||||
**Status: experimental, playable.** The game boots, loads saves and plays (the Hunter's Dream
|
||||
@@ -152,9 +154,10 @@ neither has nor includes.
|
||||
Game of the Year edition's.
|
||||
A dumped update is a separate folder: copy it over the base game, replacing files. The base
|
||||
game alone (1.00) crashes at start (guest offset 0x20348b8); the launcher and `run.sh` check
|
||||
the executable and say what is missing (`BB_SKIP_GAME_CHECK=1` skips the check). Some dumps
|
||||
ship the 1.09 eboot.bin with Lance McDonald's 60 fps patch baked in; that one crashes when the
|
||||
gestures menu opens, and the check asks for a clean eboot.bin (bbport has its own 60 fps).
|
||||
the executable and say what is missing (`BB_SKIP_GAME_CHECK=1` skips the check). The other
|
||||
retail releases (the US `CUSA00900`, the Asian `CUSA03023`, ...) run the same 1.09 executable. Some dumps ship it
|
||||
with Lance McDonald's 60 fps patch baked in, which crashes when the gestures menu opens: the
|
||||
check asks for a clean eboot.bin then (bbport has its own 60 fps).
|
||||
- To build: GCC, CMake, Ninja, Python 3, glslang, SDL3, Vulkan headers and the libraries in
|
||||
`shell.nix`. With [Nix](https://nixos.org) everything comes from `shell.nix` automatically.
|
||||
|
||||
@@ -173,13 +176,21 @@ bash launcher/bb-launcher.sh # launcher/install-desktop.sh adds it to th
|
||||
```
|
||||
|
||||
By default the game folder is expected next to the repository (`../CUSA03173`). Saves and the
|
||||
shader cache go to `user/` (the launcher lets you choose another folder); settings to
|
||||
`bbport.ini`. A gamepad is used through SDL3 (the launcher's *Controls → Controller* picks one
|
||||
when several are connected; `BB_GAMEPAD=<GUID or part of the name>`). The keyboard works too,
|
||||
shader cache go to `user/` (the launcher lets you choose another folder, and imports saves from
|
||||
shadPS4, another port or a PS4 save exported unencrypted, after backing up the current ones in
|
||||
`user/savedata-backups/`; `scripts/save_import.py`); settings to `bbport.ini`. A gamepad is used
|
||||
through SDL3 (the launcher's *Controls → Controller* picks one when several are connected,
|
||||
`BB_GAMEPAD=<GUID or part of the name>`; with none picked, the one you press a button on or move
|
||||
takes over). The keyboard works too,
|
||||
also next to a connected gamepad (the Steam Deck always has one); both are remapped in the
|
||||
launcher (*Controls*). The character name is typed on the keyboard in a box over the game.
|
||||
The touchpad: its left half (Tab, Back/Select) opens the gestures, the right half (Backspace)
|
||||
the key items. There is no PSN: the *Play online / offline* screen is skipped and the game opens
|
||||
the key items. On a controller without a touchpad (Xbox), Back + d-pad left / right clicks the
|
||||
left / right half. *Controls* in the launcher also swaps the sticks and inverts the controller's
|
||||
camera (`pad_swap_sticks`, `pad_invert_look_x`, `pad_invert_look_y` in bbport.ini). With *Direct
|
||||
mouse camera* (`mouse_camera=direct`) the mouse turns the camera itself, 1:1, with no stick dead
|
||||
zone or turn curve (the game's 1.09 code is hooked while the window holds the mouse; locked on, the
|
||||
mouse moves the stick again so a flick switches targets). There is no PSN: the *Play online / offline* screen is skipped and the game opens
|
||||
its main menu offline (a game patch; the launcher's *Game effects → Skip the “play online / offline” choice*,
|
||||
`BB_SKIP_NETWORK_CHOICE=0` shows it). With several monitors, *Screen → Monitor* in the launcher
|
||||
(`BB_DISPLAY=<number or part of the name>`) picks the one for the game.
|
||||
@@ -239,7 +250,8 @@ For the game debug menu, install `DbgFont14h.ccm` and `DbgFont14h.tpf` from
|
||||
the unsafe patch. Open it with the left touchpad / Tab (with the debug menu on, the left half
|
||||
no longer opens the gestures); Backspace is the right touchpad.
|
||||
Touch coordinates are forwarded from SDL gamepads; Back/Select emulates a left click on
|
||||
pads without a touch surface. The port's settings menu remains Insert / L3+R3.
|
||||
pads without a touch surface, Back + d-pad left / right a click on that half (while Back is bound
|
||||
to the touchpad only, the default). The port's settings menu remains Insert / L3+R3.
|
||||
|
||||
GPU occlusion queries still use synthetic pixel counters (`PixelPipeStatDump`), and
|
||||
`IT_SET_PREDICATION` is unimplemented. Free camera allows visual investigation; it does
|
||||
@@ -284,6 +296,20 @@ DLSS gets the inputs FSR 3.1 gets (scene color, depth, motion vectors, jitter).
|
||||
libraries, on other GPUs or with `BB_DLSS=0` it is listed as unavailable, and a DLSS setting falls
|
||||
back to FSR 3.1.
|
||||
|
||||
On Linux, DLSS also needs NVIDIA's CUDA/UVM runtime. If device creation fails with the DLSS
|
||||
extensions, the renderer retries without them and uses FSR 3.1. If `/dev/nvidia-uvm` is missing,
|
||||
load the installed driver module with `sudo modprobe nvidia_uvm` and restart the game. Installing
|
||||
the distribution's `nvidia-modprobe` package lets NVIDIA's libraries load the module when needed.
|
||||
If `modprobe` reports `Key was rejected by service` with Secure Boot enabled, enroll the
|
||||
module's signing key using the distribution's [MOK procedure](https://documentation.ubuntu.com/security/security-features/platform-protections/secure-boot/#mok-generation-and-signing-process).
|
||||
|
||||
The AppImage built with `DLSS_SDK_ROOT` carries the bridge and NVIDIA's library (NVIDIA's RTX SDK
|
||||
license allows that in an application; its text is `NVIDIA-DLSS-LICENSE.txt` in the package). A
|
||||
newer `libnvidia-ngx-dlss.so.<version>` (from the SDK's `lib/Linux_x86_64/rel`) is chosen in the
|
||||
launcher: *Upscaler → DLSS: NVIDIA's library → Choose a file…*, as the FSR 4.1.1 DLL; it is
|
||||
copied to `<user>/dlss/`, which the game prefers. A game's `nvngx_dlss.dll` is the Windows
|
||||
library: NVIDIA's Linux driver does not load it.
|
||||
|
||||
From source the script takes its tools from the system (MinGW GCC, CMake, Ninja, Python 3,
|
||||
SPIRV-Tools, Git) or from Nix; the AppImage has them prebuilt.
|
||||
|
||||
@@ -361,6 +387,15 @@ precise and slow, 0 off).
|
||||
More in [docs/](docs); recent changes: [docs/CHANGES_0.4.md](docs/CHANGES_0.4.md) (in Russian),
|
||||
[docs/CHANGES_2026-10-06.md](docs/CHANGES_2026-10-06.md).
|
||||
|
||||
**Online play (shadNet).** A separate module, `out/gpu/libbbnet.so` (`gpu/bbnet`: shadPS4's network
|
||||
and PSN libraries from shadp2p, a shadPS4 fork, with the shadNet client), built when protobuf,
|
||||
nlohmann_json, OpenSSL, zlib and miniupnpc are there (`shell.nix`) and included in the AppImage.
|
||||
The launcher's *Online* page turns it on (`BB_ONLINE=1`): messages, bloodstains and ghosts come
|
||||
from The Hunter's Dream, bells and summons go through a shadNet server with a shadNet account (Online ID and password):
|
||||
The Hunter's Dream's pool through bbbridge, or a server of your own (`docs/SERVER.md`); shadPS4's
|
||||
public one (`srv.shadps4.net:31313`) no longer accepts bbport builds. Offline the module is never loaded; a
|
||||
build without it plays offline only.
|
||||
|
||||
## Repository layout
|
||||
|
||||
| Path | Contents |
|
||||
|
||||
+41
-5
@@ -23,7 +23,9 @@ bbport — это аналог Wine + DXVK, только для одной иг
|
||||
До полной модели Wine + DXVK остался один шаг: новая модель и на NVIDIA, затем удаление старой
|
||||
модели памяти (см. ниже).
|
||||
|
||||
> **Файлов игры в репозитории нет.** Нужен свой дамп Bloodborne (CUSA03173, v1.09).
|
||||
> **Файлов игры в репозитории нет.** Нужен свой дамп Bloodborne с обновлением 1.09: любое издание
|
||||
> из магазина (CUSA00900 в США, CUSA03173 — европейское Game of the Year, ...): у них один и тот же
|
||||
> исполняемый файл.
|
||||
> Проект не связан с Sony Interactive Entertainment, FromSoftware и AMD.
|
||||
|
||||
**Состояние: экспериментальный, играбельный.** Игра запускается, загружает сохранения и
|
||||
@@ -146,6 +148,9 @@ bbport — это аналог Wine + DXVK, только для одной иг
|
||||
Дамп обновления — отдельная папка: скопируйте её поверх базовой игры с заменой файлов. Одна
|
||||
базовая игра (1.00) падает при запуске (guest offset 0x20348b8); лаунчер и `run.sh` проверяют
|
||||
исполняемый файл и пишут, чего не хватает (`BB_SKIP_GAME_CHECK=1` отключает проверку).
|
||||
Другие издания (американское `CUSA00900`, азиатское `CUSA03023`, ...) запускают тот же
|
||||
исполняемый файл 1.09. В некоторых дампах в него уже вшит патч 60 FPS от Lance McDonald, из-за которого игра
|
||||
падает при открытии меню жестов: проверка тогда просит чистый eboot.bin (у bbport свои 60 FPS).
|
||||
- Для сборки: GCC, CMake, Ninja, Python 3, glslang, SDL3, заголовки Vulkan и библиотеки из
|
||||
`shell.nix`. С [Nix](https://nixos.org) всё берётся из `shell.nix` автоматически.
|
||||
|
||||
@@ -164,12 +169,19 @@ bash launcher/bb-launcher.sh # launcher/install-desktop.sh добави
|
||||
```
|
||||
|
||||
По умолчанию папка игры ищется рядом с репозиторием (`../CUSA03173`). Сохранения и кэш шейдеров
|
||||
— в `user/` (в лаунчере можно выбрать другую папку), настройки — в `bbport.ini`. Геймпад через
|
||||
SDL3 (если их несколько, нужный выбирается в лаунчере: *Управление → Контроллер*;
|
||||
`BB_GAMEPAD=<GUID или часть имени>`). Клавиатура тоже работает, и при подключённом геймпаде
|
||||
— в `user/` (в лаунчере можно выбрать другую папку и импортировать сохранения из shadPS4,
|
||||
другого порта или выгруженные с PS4 без шифрования; текущие сначала копируются в
|
||||
`user/savedata-backups/`; `scripts/save_import.py`), настройки — в `bbport.ini`. Геймпад через
|
||||
SDL3 (если их несколько, нужный выбирается в лаунчере: *Управление → Контроллер*,
|
||||
`BB_GAMEPAD=<GUID или часть имени>`; если не выбран, управляет тот, на котором нажали кнопку или
|
||||
двинули стик). Клавиатура тоже работает, и при подключённом геймпаде
|
||||
(на Steam Deck он есть всегда); обе раскладки меняются в лаунчере (*Управление*). Имя персонажа
|
||||
вводится с клавиатуры в окне поверх игры. Тачпад: левая половина (Tab, Back/Select) открывает
|
||||
жесты, правая (Backspace) — личные вещи. PSN нет: экран «Играть по сети / вне сети» пропускается,
|
||||
жесты, правая (Backspace) — личные вещи. На геймпаде без тачпада (Xbox) Back + крестовина
|
||||
влево / вправо нажимает левую / правую половину. В лаунчере (*Управление*) можно поменять стики
|
||||
местами и инвертировать камеру геймпада (`pad_swap_sticks`, `pad_invert_look_x`, `pad_invert_look_y` в bbport.ini). *Прямая камера мышью*
|
||||
(`mouse_camera=direct`): мышь поворачивает камеру сама, 1:1, без мёртвой зоны и кривой стика (код
|
||||
игры 1.09 перехватывается, пока окно держит мышь; при захвате цели мышь снова двигает стик). PSN нет: экран «Играть по сети / вне сети» пропускается,
|
||||
игра сразу открывает главное меню вне сети (патч игры; в лаунчере *Эффекты игры → Пропускать
|
||||
выбор «по сети / вне сети»*, `BB_SKIP_NETWORK_CHOICE=0` — показывать). При нескольких мониторах нужный выбирается в
|
||||
лаунчере: *Экран → Монитор* (`BB_DISPLAY=<номер или часть имени>`).
|
||||
@@ -254,6 +266,21 @@ Experimental от октября 2026; Proton 11.0 из Steam и GE-Proton 9 —
|
||||
векторы движения, jitter). Без библиотек, на других видеокартах или с `BB_DLSS=0` он недоступен,
|
||||
а выбранный DLSS заменяется на FSR 3.1.
|
||||
|
||||
На Linux DLSS также нужен CUDA/UVM из драйвера NVIDIA. Если устройство не создаётся с
|
||||
расширениями DLSS, рендерер повторяет попытку без них и использует FSR 3.1. Если отсутствует
|
||||
`/dev/nvidia-uvm`, загрузи установленный модуль командой `sudo modprobe nvidia_uvm` и перезапусти
|
||||
игру. Пакет `nvidia-modprobe` из репозитория дистрибутива позволяет библиотекам NVIDIA загружать
|
||||
модуль по мере необходимости.
|
||||
Если при включённом Secure Boot команда `modprobe` сообщает `Key was rejected by service`,
|
||||
зарегистрируй ключ подписи модуля по [процедуре MOK своего дистрибутива](https://documentation.ubuntu.com/security/security-features/platform-protections/secure-boot/#mok-generation-and-signing-process).
|
||||
|
||||
AppImage, собранный с `DLSS_SDK_ROOT`, содержит мост и библиотеку NVIDIA (лицензия RTX SDK от NVIDIA
|
||||
это разрешает в составе приложения; её текст — `NVIDIA-DLSS-LICENSE.txt` в пакете). Более новая
|
||||
`libnvidia-ngx-dlss.so.<версия>` (из `lib/Linux_x86_64/rel` в SDK) выбирается в лаунчере:
|
||||
*Апскейлер → DLSS: библиотека NVIDIA → Выбрать файл…*, как DLL для FSR 4.1.1; она копируется в
|
||||
`<user>/dlss/`, и игра берёт её первой. `nvngx_dlss.dll` из игр — библиотека для Windows: драйвер
|
||||
NVIDIA на Linux её не загружает.
|
||||
|
||||
Скрипт из исходников берёт инструменты из системы (MinGW GCC, CMake, Ninja, Python 3,
|
||||
SPIRV-Tools, Git) или из Nix; в AppImage они уже собраны.
|
||||
|
||||
@@ -331,6 +358,15 @@ VRAM считается не больше N МиБ: проверка повед
|
||||
Подробнее — в [docs/](docs); последние изменения — [docs/CHANGES_0.4.md](docs/CHANGES_0.4.md),
|
||||
[docs/CHANGES_2026-10-06.md](docs/CHANGES_2026-10-06.md).
|
||||
|
||||
**Игра по сети (shadNet).** Отдельный модуль `out/gpu/libbbnet.so` (`gpu/bbnet`: сетевые и PSN-библиотеки
|
||||
shadPS4 из shadp2p, форка shadPS4, с клиентом shadNet); собирается, когда есть protobuf, nlohmann_json,
|
||||
OpenSSL, zlib и miniupnpc (`shell.nix`), входит в AppImage. Включается на странице лаунчера *Онлайн*
|
||||
(`BB_ONLINE=1`): сообщения, пятна крови и призраки — с The Hunter's Dream, колокола и призывы — через
|
||||
сервер shadNet с учётной записью shadNet (Online ID и пароль): пул The Hunter's Dream через bbbridge
|
||||
или свой сервер (`docs/SERVER.md`); публичный сервер shadPS4 (`srv.shadps4.net:31313`) больше не
|
||||
принимает сборки bbport.
|
||||
Офлайн модуль не загружается; сборка без него играет только офлайн.
|
||||
|
||||
## Устройство репозитория
|
||||
|
||||
| Путь | Содержимое |
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Bloodborne for Linux
|
||||
|
||||
[](https://github.com/ayozetr/bloodborne_linux/releases/latest)
|
||||
[](https://github.com/ayozetr/bloodborne_linux/releases/latest)
|
||||
[](LICENSE)
|
||||
[](#features)
|
||||
[](https://ko-fi.com/ayozetr)
|
||||
|
||||
The PlayStation 4 version of *Bloodborne* running natively on Linux. The game's own x86-64 code
|
||||
runs directly on the CPU and its graphics are translated to Vulkan. It is not an emulator.
|
||||
|
||||
@@ -7,20 +13,21 @@ This fork builds on [bbport](https://github.com/deadinside28/bloodborne_pc), the
|
||||
by deadinside28. It adds the features of [Supermedo's Windows port](https://github.com/Supermedo/bloodborne_pc)
|
||||
(online play, DLSS, cheats) brought to Linux, plus a few of its own.
|
||||
|
||||
> **No game files are included.** You need your own decrypted dump of Bloodborne 1.09: the
|
||||
> European Game of the Year edition (`CUSA03173`) or the US release (`CUSA00900`). This project is
|
||||
> not affiliated with Sony Interactive Entertainment or FromSoftware.
|
||||
> **No game files are included.** You need your own decrypted dump of Bloodborne with update
|
||||
> 1.09, from a retail release: [which ones, and which have been checked](docs/COMPATIBILITY.md).
|
||||
> This project is not affiliated with Sony Interactive Entertainment or FromSoftware.
|
||||
|
||||
## Features
|
||||
|
||||
- **Online play (beta)** through [The Hunter's Dream](https://thehuntersdream.com) and shadNet:
|
||||
messages, bloodstains, ghosts, bells and summons. Linux and Windows players can play together.
|
||||
- **Online play (beta)** through [The Hunter's Dream](https://thehuntersdream.com): messages,
|
||||
bloodstains, ghosts, bells and summons, with bbhost's players and Windows players of bbport, or
|
||||
with your group on [a server of your own](docs/SERVER.md).
|
||||
- **Upscaling:** AMD FSR 3.1, FSR 4 and FSR 4.1.1, **NVIDIA DLSS** on GeForce RTX, and
|
||||
native-resolution TAA.
|
||||
- **Unlocked frame rate**, capped at your display's refresh rate up to 120 FPS (above that the
|
||||
game's own movement timing breaks), or 30/60/90 FPS. Output from 720p to 4K.
|
||||
- **Launcher** with every setting in one window, in English, Spanish, Italian, French, German,
|
||||
Portuguese and Russian, like the in-game menu (Insert or L3+R3).
|
||||
Portuguese, Russian and Simplified Chinese, like the in-game menu (Insert or L3+R3).
|
||||
- **Settings in the game's own System menu** as well (Display and Effects pages), in the game's
|
||||
language.
|
||||
- **Two memory models:** the new one, where the GPU works with the game's memory as in a PC game
|
||||
@@ -29,12 +36,17 @@ by deadinside28. It adds the features of [Supermedo's Windows port](https://gith
|
||||
menu without the motion-control icons.
|
||||
- **Cheats and gameplay tweaks:** never die, stealth, silent steps, enemy control, camera
|
||||
distance, ragdoll physics and more.
|
||||
- **Trophies:** a notice in the game, in the style of the PS4's, and a Trophies page in the
|
||||
launcher with every trophy, from your own dump. Their names and descriptions need the PS4's
|
||||
trophy key (shadPS4's TrophyKey), which you enter in the launcher; without it, only the icons.
|
||||
- **Game effects** on and off, **mods** and community **patches**, loaded without changing the
|
||||
game files.
|
||||
- **Add to Steam** in one click (native or Flatpak Steam), and **Discord Rich Presence**.
|
||||
game files ([how](docs/MODS.en.md)).
|
||||
- **Add to Steam** in one click (native or Flatpak Steam), and **Discord Rich Presence** with the
|
||||
area you are in and your co-op partners.
|
||||
- **AppImage:** one file with everything inside, for any distribution and the Steam Deck. It
|
||||
updates itself: the launcher's **About** page checks this repository's releases and installs
|
||||
a new AppImage over the old one.
|
||||
a new AppImage over the old one, or any earlier release from 0.5-linux.1 on, to go back to one
|
||||
that worked better.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -71,18 +83,32 @@ With [Nix](https://nixos.org), `bash build.sh` gets every dependency from `shell
|
||||
|
||||
### Online play
|
||||
|
||||
1. Register a shadNet account at [shadnet.shadps4.net](https://shadnet.shadps4.net).
|
||||
2. In the launcher, open **Online**, turn on **Play online** and enter your account name (not
|
||||
your email) and password. **Test the connection**: all three servers should be reachable.
|
||||
1. In the launcher, open **Online**, turn on **Play online** and choose the co-op server:
|
||||
- **The Hunter's Dream (with bbhost players)**: sign in with Discord, or create an account
|
||||
with just a name and keep the recovery code it shows (it brings the account back on
|
||||
another PC). [bbbridge](bbbridge/README.md) by MaskZeus runs with the game and talks to
|
||||
The Hunter's Dream the way bbhost does, so you play with bbhost's players and with Windows
|
||||
players of bbport on the same pool.
|
||||
- **Your own server**: your group's shadNet server and your account on it
|
||||
([docs/SERVER.md](docs/SERVER.md), Docker and Tailscale; tested with a Linux and a Windows
|
||||
player).
|
||||
2. **Test the connection**: every line should be green.
|
||||
3. Start the game and choose online at the title screen.
|
||||
|
||||
Everyone in a session needs game version 1.09 and the same servers. Turn cheats off when you
|
||||
play with others.
|
||||
**Seamless co-op (experimental)**: turn on **Seamless co-op (BBSC)** in the Online page and the
|
||||
session lives through deaths, lamps, areas, bosses and the Hunter's Dream, with up to 5 hunters.
|
||||
It is [BBSC](https://github.com/Classmg22/BBSC) by CLS_AMG, built for Linux here; every player of
|
||||
the session needs it (on Windows, BBSC's package for the Windows port). Summons across areas need
|
||||
BBSC's game server ([docs/ONLINE.md](docs/ONLINE.md#seamless-co-op-bbsc)).
|
||||
|
||||
Co-op connects the players directly over UDP. With ufw or firewalld on, the launcher's **Online**
|
||||
page says so and gives the commands: pin the port with `SHADPS4_P2P_PORT=3658` in the extra
|
||||
variables, open it (`sudo ufw allow 3658/udp`), and for UPnP let the router's answers in
|
||||
(`sudo ufw allow from <router> proto udp`).
|
||||
Everyone in a session needs game version 1.09 and the same pool. Turn cheats off when you play
|
||||
with others. shadPS4's public shadNet server no longer accepts bbport builds: it signs them out
|
||||
right after the login, and the launcher says so.
|
||||
|
||||
With your own server, co-op connects the players directly over UDP. UPnP opens the port on the router by itself; with
|
||||
ufw or firewalld on, the launcher's **Online** page says so and gives the commands: pin the
|
||||
**Co-op port** (for example 3658), open it (`sudo ufw allow 3658/udp`), and for UPnP let the
|
||||
router's answers in (`sudo ufw allow from <router> proto udp`). [More in docs/ONLINE.md](docs/ONLINE.md).
|
||||
|
||||
### DLSS
|
||||
|
||||
@@ -90,6 +116,12 @@ On a PC with a GeForce RTX, run `bash tools/build_dlss.sh` once after `build.sh`
|
||||
what it needs from the NVIDIA DLSS SDK and builds the bridge. Then choose **DLSS** as the
|
||||
upscaler.
|
||||
|
||||
### Environment variables
|
||||
|
||||
The launcher's **Settings → Developer → Extra variables** field (`NAME=value`, space-separated)
|
||||
passes environment variables to the game, the same ones `run.sh` takes from a terminal. Every one
|
||||
the port reads, what it does and which win over which: [docs/ENVIRONMENT.md](docs/ENVIRONMENT.md).
|
||||
|
||||
### DLC
|
||||
|
||||
The Game of the Year edition (`CUSA03173`) already includes The Old Hunters. With the base game
|
||||
@@ -118,6 +150,11 @@ Keyboard**. **Gestures menu without motion-control icons** cleans up the gesture
|
||||
prompts are put into your own game's menu files at each start, and nothing in the game folder
|
||||
changes.
|
||||
|
||||
## Support
|
||||
|
||||
If this fork is useful to you and you would like to support it, you can on
|
||||
[Ko-fi](https://ko-fi.com/ayozetr). The port itself is deadinside28's work (see below).
|
||||
|
||||
## Credits
|
||||
|
||||
- [deadinside28/bloodborne_pc](https://github.com/deadinside28/bloodborne_pc): **bbport**, the
|
||||
@@ -129,6 +166,11 @@ changes.
|
||||
- [shadp2p](https://github.com/Wozzardman/shadp2p) by Wozzardman: the network and PSN libraries
|
||||
for online play. Servers by the [shadPS4](https://shadps4.net) team and
|
||||
[The Hunter's Dream](https://thehuntersdream.com) by droogie and its team.
|
||||
- [bbbridge](https://github.com/MaskZeus/bbbridge) by MaskZeus (GPL-3.0-or-later): The Hunter's
|
||||
Dream's co-op pool for bbport, built for Linux here ([bbbridge/](bbbridge/)).
|
||||
- [BBSC](https://github.com/Classmg22/BBSC) by CLS_AMG (GPL-2.0-or-later): seamless co-op, with
|
||||
Wozzardman's seamless summon; built for Linux here ([bbsc/](bbsc/)), with
|
||||
[xbyak](https://github.com/herumi/xbyak) and [fmt](https://github.com/fmtlib/fmt).
|
||||
- [IFreemz/shadPS4-Bloodborne-DLSS-FSR](https://github.com/IFreemz/shadPS4-Bloodborne-DLSS-FSR):
|
||||
the DLSS bridge, and the [NVIDIA DLSS SDK](https://github.com/NVIDIA/DLSS).
|
||||
- [FSR-Vulkan](https://github.com/FireBurn/FSR-Vulkan) by FireBurn and the AMD FidelityFX SDK.
|
||||
@@ -140,6 +182,9 @@ changes.
|
||||
which textures keyboard prompts need (after the idea of
|
||||
[mod 460](https://www.nexusmods.com/bloodborne/mods/460)); bbport draws its own.
|
||||
- [quick-sharun](https://github.com/pkgforge-dev/Anylinux-AppImages) for the AppImage.
|
||||
- [ps4-pkg-extractor](https://github.com/paulomanrique/ps4-pkg-extractor) by paulomanrique
|
||||
(Unlicense), on [orbis-pkg-util](https://crates.io/crates/orbis-pkg-util): the launcher's
|
||||
*Install from PKG*.
|
||||
- Game patches by Kyo, Lance McDonald, auser1337, illusion, emoose and the Bloodborne community.
|
||||
|
||||
Full list of components and licenses: [BBPORT.md](BBPORT.md#credits-and-licenses).
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Your account (token + recovery code): never commit or share it.
|
||||
bbbridge_state.json
|
||||
bbbridge_state.json.*
|
||||
# Per-PC settings (the chosen bbport folder, ...)
|
||||
bbbridge_config.json
|
||||
|
||||
# Logs and trace captures (they can hold session ids)
|
||||
*.log
|
||||
captures/
|
||||
|
||||
# Build output
|
||||
build/
|
||||
bin/
|
||||
bbbridge.exe
|
||||
.vs/
|
||||
out/
|
||||
__pycache__/
|
||||
|
||||
# Release packages (tools\make_release.ps1)
|
||||
dist/
|
||||
@@ -0,0 +1,40 @@
|
||||
# Changelog
|
||||
|
||||
## 0.3.2 - 2026-10-12
|
||||
|
||||
- **Fix: "Failed to acquire Bloodborne server information" after closing
|
||||
bbbridge.** The file that sends the game's server traffic to bbbridge
|
||||
(`host_overrides.custom.json` in bbport's user folder) now exists only while
|
||||
bbbridge runs with the switch on The Hunter's Dream: it goes when bbbridge
|
||||
closes, when the bridge stops, or when bbport is set back to shadNet - also
|
||||
in the bbport launcher itself. One left behind by an older bbbridge
|
||||
(including the Python 0.2.0) is cleaned up the next time bbbridge starts.
|
||||
- Troubleshooting notes in the README.
|
||||
|
||||
## 0.3.1 - 2026-10-12
|
||||
|
||||
- **Online switch** in place of the "Route bbport through bbbridge" box:
|
||||
shadNet on the left, The Hunter's Dream (bbhost) on the right. A line under
|
||||
it says which pool and account PLAY uses.
|
||||
- **shadNet comes back whole**: switching to shadNet restores bbport's own
|
||||
server *and* shadNet account (name, password). The Hunter's Dream account is
|
||||
bbport's online id only while switched to it. PLAY follows the switch; on
|
||||
shadNet no Discord sign-in is needed.
|
||||
- **Set up by default**: once signed in with Discord, the switch goes to The
|
||||
Hunter's Dream and the live map patch is added; turning either off sticks.
|
||||
- **bbport folder**: shows where it was found; bbbridge.exe right next to
|
||||
Bloodborne.exe works too; asks when it had to guess, when a chosen folder is
|
||||
gone, or when bbport is not found.
|
||||
- **Clear errors**: PLAY and "bbport settings..." say what is missing (sign-in,
|
||||
game folder, shadNet account, launcher open, missing exe, Windows' error).
|
||||
"bbport settings..." also works before the launcher has any settings.
|
||||
- **Lighter**: the window draws at full rate only while used (idle CPU about a
|
||||
quarter of before).
|
||||
- **Steadier event poll**: it no longer stalls ~8 s about once a minute, so a
|
||||
summon reaches the game without that delay.
|
||||
- Verified live: joining a bbhost host's world as a guest (Small Resonant Bell).
|
||||
|
||||
## 0.3.0 - 2026-10-12
|
||||
|
||||
- First C++ release: one window for the bridge, Discord sign-in, routing bbport,
|
||||
the live map patch and starting the game.
|
||||
@@ -0,0 +1,81 @@
|
||||
cmake_minimum_required(VERSION 3.21)
|
||||
project(bbbridge VERSION 0.3.2 LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
# One self-contained exe: the C++ runtime is linked in (no VC++ redistributable).
|
||||
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
|
||||
if(MSVC)
|
||||
add_compile_options(/utf-8 /permissive- /EHsc)
|
||||
add_compile_definitions(_WIN32_WINNT=0x0A00 WINVER=0x0A00 UNICODE _UNICODE _CRT_SECURE_NO_WARNINGS)
|
||||
endif()
|
||||
|
||||
# The bridge itself: everything but the front ends.
|
||||
add_library(bbbridge_core STATIC
|
||||
src/util.cpp
|
||||
src/proto3.cpp
|
||||
src/http.cpp
|
||||
src/bbhost_client.cpp
|
||||
src/p2p.cpp
|
||||
src/bridge.cpp
|
||||
)
|
||||
target_include_directories(bbbridge_core PUBLIC src third_party)
|
||||
target_sources(bbbridge_core PRIVATE src/account_link.cpp)
|
||||
if(WIN32)
|
||||
target_link_libraries(bbbridge_core PUBLIC ws2_32 winhttp bcrypt)
|
||||
# Pointing bbport for Windows at the bridge (its launcher's settings, host overrides).
|
||||
target_sources(bbbridge_core PRIVATE src/setup.cpp)
|
||||
else()
|
||||
# Linux: BSD sockets, libcurl for HTTPS, OpenSSL for SHA-256. The bbport launcher points the
|
||||
# game at the bridge itself, so there is no setup.cpp, and no window (bbbridge-cli only).
|
||||
find_package(Threads REQUIRED)
|
||||
find_package(CURL REQUIRED)
|
||||
find_package(OpenSSL REQUIRED COMPONENTS Crypto)
|
||||
target_link_libraries(bbbridge_core PUBLIC Threads::Threads CURL::libcurl OpenSSL::Crypto)
|
||||
target_compile_options(bbbridge_core PRIVATE -Wall -Wextra)
|
||||
endif()
|
||||
|
||||
if(WIN32)
|
||||
# Dear ImGui (third_party/imgui, v1.91.5) with the Win32 + DirectX 11 backends.
|
||||
add_library(imgui STATIC
|
||||
third_party/imgui/imgui.cpp
|
||||
third_party/imgui/imgui_draw.cpp
|
||||
third_party/imgui/imgui_tables.cpp
|
||||
third_party/imgui/imgui_widgets.cpp
|
||||
third_party/imgui/imgui_impl_win32.cpp
|
||||
third_party/imgui/imgui_impl_dx11.cpp
|
||||
)
|
||||
target_include_directories(imgui PUBLIC third_party/imgui)
|
||||
target_compile_definitions(imgui PUBLIC IMGUI_DISABLE_DEMO_WINDOWS)
|
||||
|
||||
|
||||
# The app: bbbridge.exe, next to this CMakeLists (inside bbport's folder).
|
||||
add_executable(bbbridge WIN32 src/app.cpp src/app.rc)
|
||||
target_link_libraries(bbbridge PRIVATE bbbridge_core imgui d3d11 dxgi)
|
||||
add_custom_command(TARGET bbbridge POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different $<TARGET_FILE:bbbridge> ${CMAKE_SOURCE_DIR}/bbbridge.exe)
|
||||
endif()
|
||||
|
||||
# Headless bridge: tests, servers, troubleshooting.
|
||||
add_executable(bbbridge-cli src/main_cli.cpp)
|
||||
target_link_libraries(bbbridge-cli PRIVATE bbbridge_core)
|
||||
|
||||
# Unit tests for the byte-level parts (framing, signaling, STUN, protobuf).
|
||||
add_executable(bbbridge-unit tests/unit.cpp)
|
||||
target_link_libraries(bbbridge-unit PRIVATE bbbridge_core)
|
||||
|
||||
# Warnings at /W4 for our code (not for third_party).
|
||||
if(MSVC)
|
||||
foreach(target bbbridge_core bbbridge bbbridge-cli bbbridge-unit)
|
||||
target_compile_options(${target} PRIVATE /W4)
|
||||
endforeach()
|
||||
endif()
|
||||
|
||||
# The CLI and the unit tests land in bin\ (on Windows; elsewhere they stay in the build folder).
|
||||
if(WIN32)
|
||||
foreach(target bbbridge-cli bbbridge-unit)
|
||||
add_custom_command(TARGET ${target} POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different $<TARGET_FILE:${target}> ${CMAKE_SOURCE_DIR}/bin/$<TARGET_FILE_NAME:${target}>)
|
||||
endforeach()
|
||||
endif()
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"version": 3,
|
||||
"configurePresets": [
|
||||
{
|
||||
"name": "x64-release",
|
||||
"displayName": "x64 Release",
|
||||
"generator": "Ninja",
|
||||
"binaryDir": "${sourceDir}/build/${presetName}",
|
||||
"architecture": { "value": "x64", "strategy": "external" },
|
||||
"cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "CMAKE_CXX_COMPILER": "cl" }
|
||||
},
|
||||
{
|
||||
"name": "x64-debug",
|
||||
"displayName": "x64 Debug",
|
||||
"inherits": "x64-release",
|
||||
"cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" }
|
||||
}
|
||||
],
|
||||
"buildPresets": [
|
||||
{ "name": "x64-release", "configurePreset": "x64-release" },
|
||||
{ "name": "x64-debug", "configurePreset": "x64-debug" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,92 @@
|
||||
# bbbridge (C++)
|
||||
|
||||
Play **co-op with bbhost players** on **The Hunter's Dream** while using
|
||||
**bbport** (Supermedo's `bloodborne_pc` for Windows, DLSS / FSR 3.1 / FSR 4).
|
||||
|
||||
bbbridge looks like a shadNet server to bbport and talks to The Hunter's Dream
|
||||
the way bbhost does: matching (rooms, bells, summons), the P2P traffic between
|
||||
the two clients (different packet framing, bbport's signaling handshake,
|
||||
The Hunter's Dream's STUN and relay), and the game's own server traffic
|
||||
(account token, play log for the live map). bbport itself is not modified.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
E:\bloodborne\ bbport
|
||||
└─ bbbridge\ this folder
|
||||
├─ bbbridge.exe the app (built)
|
||||
├─ bbbridge_config.json settings (optional)
|
||||
├─ bbbridge_state.json your account: token + recovery code - never share it
|
||||
├─ bbbridge.log log of the last run (previous: bbbridge.prev.log)
|
||||
├─ src\ C++ sources
|
||||
├─ tests\ unit tests (C++) and end-to-end tests (Python)
|
||||
├─ third_party\ nlohmann/json 3.11.3, Dear ImGui 1.91.5
|
||||
└─ bin\ bbbridge-cli.exe (headless), bbbridge-unit.exe
|
||||
```
|
||||
<img width="798" height="718" alt="resim" src="https://github.com/user-attachments/assets/672a0038-4ead-4572-9293-b6c3936ed128" />
|
||||
|
||||
|
||||
## Using it
|
||||
|
||||
1. Put the `bbbridge` folder inside bbport's folder (next to `Bloodborne.exe`)
|
||||
and start `bbbridge.exe` (close the bbport launcher first). bbbridge uses
|
||||
the bbport it sits in; anywhere else it asks which bbport to use
|
||||
(**Change...** picks another one later).
|
||||
2. **Sign in with Discord**: the app shows a code and opens The Hunter's Dream;
|
||||
sign in there with your Discord account and approve the code. Your account
|
||||
there (the same one bbhost uses) becomes bbport's online id. No password is
|
||||
stored; the account's token goes to `bbbridge_state.json`.
|
||||
3. The **Online** switch goes to **The Hunter's Dream (bbhost)** by itself
|
||||
once you are signed in (and the live map patch is added; untick it if you
|
||||
like).
|
||||
4. Press **PLAY**. The app starts the bridge if needed and launches the game
|
||||
with bbport's saved settings. You can also start the game from the bbport
|
||||
launcher - as long as bbbridge is open. Graphics, mods and cheats stay in
|
||||
the bbport launcher (**bbport settings...**) or the in-game menu (Insert).
|
||||
|
||||
Move the **Online** switch to **shadNet** to go back to bbport's own shadNet
|
||||
co-op pool: bbport gets its own server and shadNet account back (the ones it
|
||||
had before; a shadNet account is registered at
|
||||
[shadnet.shadps4.net](https://shadnet.shadps4.net) and entered in the bbport
|
||||
launcher's *Online co-op*). PLAY follows the switch: right, the bbhost pool
|
||||
with your Discord sign-in; left, bbport's own shadNet - no sign-in needed.
|
||||
Running bbhost and bbport with the same account at the same time is not
|
||||
supported.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Failed to acquire Bloodborne server information" with bbbridge closed**:
|
||||
bbbridge 0.3.1 and older (and the Python 0.2.0) left
|
||||
`user\host_overrides.custom.json` in bbport's folder (bbport's user folder),
|
||||
which sends the game's server traffic to bbbridge even when it is not
|
||||
running. Start bbbridge 0.3.2 or newer once (it removes the file when it is
|
||||
not needed), or delete that file by hand. From 0.3.2 the file exists only
|
||||
while bbbridge runs with the switch on The Hunter's Dream.
|
||||
- **Nobody comes when I ring a bell**: a Small Resonant Bell is answered only
|
||||
by a host ringing the Beckoning Bell in the same area, near your level (and
|
||||
the other way round). The live map at
|
||||
[thehuntersdream.com/map](https://thehuntersdream.com/map) shows who is
|
||||
ringing which bell where.
|
||||
|
||||
## Building (Visual Studio 2022/2026)
|
||||
|
||||
Workload **Desktop development with C++** (MSVC, Windows SDK, CMake tools).
|
||||
|
||||
- Visual Studio: *File > Open > Folder...* this folder, pick the **x64 Release**
|
||||
preset, *Build > Build All*. `bbbridge.exe` is copied here, the CLI and the
|
||||
unit tests to `bin\`.
|
||||
- Command line (Developer PowerShell):
|
||||
`cmake --preset x64-release` then `cmake --build --preset x64-release`
|
||||
|
||||
## Tests
|
||||
|
||||
- `bin\bbbridge-unit.exe` - framing, signaling, STUN, protobuf, account id.
|
||||
- `python tests\e2e\test_e2e.py` - the bridge exe against a fake bbhost
|
||||
server, a fake STUN/relay server and fake game sockets (no internet).
|
||||
|
||||
## Credits and license
|
||||
|
||||
The protocol comes from **bbhost** and The Hunter's Dream by droogie (GPL-3.0);
|
||||
**bloodborne_pc / bbport** by Supermedo on deadinside28's port and shadPS4.
|
||||
bbbridge is GPL-3.0-or-later. nlohmann/json and Dear ImGui are MIT-licensed.
|
||||
Not affiliated with Sony, FromSoftware, Supermedo or The Hunter's Dream.
|
||||
@@ -0,0 +1,51 @@
|
||||
bbbridge includes the following third-party software.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
Dear ImGui 1.91.5 - https://github.com/ocornut/imgui
|
||||
------------------------------------------------------------------------------
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-2024 Omar Cornut
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
JSON for Modern C++ 3.11.3 - https://github.com/nlohmann/json
|
||||
------------------------------------------------------------------------------
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2013-2023 Niels Lohmann <https://nlohmann.me>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,248 @@
|
||||
#include "account_link.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
|
||||
#include "http.hpp"
|
||||
|
||||
namespace bb {
|
||||
|
||||
namespace {
|
||||
|
||||
// POST to the account server; the reply is read whatever the status (a limit
|
||||
// is a 429 whose Message the player should see), as bbhost's auth_call does.
|
||||
bool auth_call(const std::string& base, const std::string& path, const json& body, json& reply, std::string& error) {
|
||||
http::ClientRequest req;
|
||||
req.url = base + path;
|
||||
req.body = body.dump();
|
||||
req.headers = {{"Content-Type", "application/json"}, {"Accept", "application/json"},
|
||||
{"User-Agent", std::string("bbbridge/") + kVersion}};
|
||||
const http::ClientResponse res = http::send(req);
|
||||
if (!res.ok()) {
|
||||
error = res.error;
|
||||
return false;
|
||||
}
|
||||
reply = json::parse(res.body, nullptr, false);
|
||||
if (reply.is_discarded() || !reply.is_object() || !reply.contains("ResKind")) {
|
||||
error = fmt("%s has no account service (HTTP %d)", base.c_str(), res.status);
|
||||
return false;
|
||||
}
|
||||
if (jint(reply, "ResKind", 1) != 0) {
|
||||
error = jstr(reply, "Message", "refused");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace {
|
||||
json read_state(const std::wstring& path) {
|
||||
std::ifstream in(fspath(path));
|
||||
if (!in) return json::object();
|
||||
json j = json::parse(in, nullptr, false);
|
||||
return j.is_object() ? j : json::object();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::string account_kind(const std::wstring& state_file, const std::string& npid) {
|
||||
const json state = read_state(state_file);
|
||||
if (!state.contains("accounts") || !state["accounts"].contains(npid)) return "";
|
||||
const json& e = state["accounts"][npid];
|
||||
if (jstr(e, "token").empty() && jstr(e, "recovery_code").empty()) return "";
|
||||
return jstr(e, "linked") == "discord" ? "discord" : "bbbridge";
|
||||
}
|
||||
|
||||
std::string website_code(const std::string& auth_server, const std::wstring& state_file, const std::string& npid,
|
||||
std::string& error) {
|
||||
const json state = read_state(state_file);
|
||||
const std::string token =
|
||||
state.contains("accounts") && state["accounts"].contains(npid) ? jstr(state["accounts"][npid], "token") : "";
|
||||
if (token.empty()) {
|
||||
error = "no account for '" + npid + "' yet - play once (or sign in with Discord) first";
|
||||
return "";
|
||||
}
|
||||
json reply;
|
||||
if (!auth_call(auth_server, "/auth/web_code", {{"Token", token}}, reply, error)) return "";
|
||||
const std::string code = jstr(reply, "Code");
|
||||
if (code.empty()) error = "no code in the reply";
|
||||
return code;
|
||||
}
|
||||
|
||||
namespace {
|
||||
bool write_state(const std::wstring& path, const json& state) {
|
||||
const std::wstring tmp = path + L".tmp";
|
||||
{
|
||||
std::ofstream out(fspath(tmp), std::ios::trunc);
|
||||
if (!out) return false;
|
||||
out << state.dump(2);
|
||||
}
|
||||
return replace_file(tmp, path);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::string recovery_code_of(const std::wstring& state_file, const std::string& name) {
|
||||
const json state = read_state(state_file);
|
||||
return state.contains("accounts") && state["accounts"].contains(name)
|
||||
? jstr(state["accounts"][name], "recovery_code")
|
||||
: "";
|
||||
}
|
||||
|
||||
std::string account_create_now(const std::string& auth_server, const std::wstring& state_file, const std::string& name) {
|
||||
json reply;
|
||||
std::string error;
|
||||
if (!auth_call(auth_server, "/auth/account/create", {{"Name", name}, {"DeviceName", device_name()}}, reply, error))
|
||||
return error;
|
||||
json state = read_state(state_file);
|
||||
if (!state["accounts"].is_object()) state["accounts"] = json::object();
|
||||
state["accounts"][name] = {{"bbhost_name", name}, {"recovery_code", jstr(reply, "RecoveryCode")},
|
||||
{"token", jstr(reply, "Token")}, {"online_id", jstr(reply, "OnlineId", name)}};
|
||||
if (!write_state(state_file, state)) return "could not write the account file";
|
||||
log("account: created '" + name + "' on this PC (recovery code stored in bbbridge_state.json)");
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string account_recover_now(const std::string& auth_server, const std::wstring& state_file, const std::string& name,
|
||||
const std::string& recovery_code) {
|
||||
json reply;
|
||||
std::string error;
|
||||
if (!auth_call(auth_server, "/auth/recover", {{"Name", name}, {"RecoveryCode", recovery_code}}, reply, error))
|
||||
return error;
|
||||
json state = read_state(state_file);
|
||||
if (!state["accounts"].is_object()) state["accounts"] = json::object();
|
||||
const std::string fresh = jstr(reply, "RecoveryCode");
|
||||
state["accounts"][name] = {{"bbhost_name", name}, {"recovery_code", fresh.empty() ? recovery_code : fresh},
|
||||
{"token", jstr(reply, "Token")}, {"online_id", jstr(reply, "OnlineId", name)}};
|
||||
if (!write_state(state_file, state)) return "could not write the account file";
|
||||
log("account: recovered '" + name + "' (its new recovery code is in bbbridge_state.json)");
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string account_sign_out(const std::string& auth_server, const std::wstring& state_file, const std::string& name) {
|
||||
json state = read_state(state_file);
|
||||
if (!state.contains("accounts") || !state["accounts"].contains(name)) return "no account for '" + name + "' here";
|
||||
json& e = state["accounts"][name];
|
||||
const std::string token = jstr(e, "token");
|
||||
if (!token.empty()) {
|
||||
json reply;
|
||||
std::string error;
|
||||
auth_call(auth_server, "/auth/logout", {{"Token", token}}, reply, error); // best effort, as bbhost
|
||||
}
|
||||
if (jstr(e, "recovery_code").empty()) state["accounts"].erase(name); // Discord: sign in again to come back
|
||||
else e.erase("token");
|
||||
if (!write_state(state_file, state)) return "could not write the account file";
|
||||
log("account: signed out of '" + name + "' on this PC");
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string device_name() {
|
||||
char host[256] = {};
|
||||
#ifdef _WIN32
|
||||
size_t n = 0;
|
||||
getenv_s(&n, host, sizeof(host), "COMPUTERNAME");
|
||||
#else
|
||||
const size_t n = gethostname(host, sizeof(host) - 1) == 0 ? std::strlen(host) + 1 : 0;
|
||||
#endif
|
||||
return n > 1 ? "bbbridge on " + std::string(host).substr(0, 48) : "bbbridge";
|
||||
}
|
||||
|
||||
double DiscordLink::seconds_left() const {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
return expires_at_ > 0 ? std::max(0.0, expires_at_ - now()) : 0.0;
|
||||
}
|
||||
|
||||
void DiscordLink::start() {
|
||||
cancel();
|
||||
cancel_ = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
user_code_.clear();
|
||||
uri_.clear();
|
||||
online_id_.clear();
|
||||
message_.clear();
|
||||
expires_at_ = 0;
|
||||
}
|
||||
state_ = State::Starting;
|
||||
worker_ = std::thread([this] { run(); });
|
||||
}
|
||||
|
||||
void DiscordLink::cancel() {
|
||||
cancel_ = true;
|
||||
if (worker_.joinable()) worker_.join();
|
||||
if (state_ == State::Starting || state_ == State::Waiting) state_ = State::Idle;
|
||||
}
|
||||
|
||||
void DiscordLink::fail(const std::string& why) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
message_ = why;
|
||||
}
|
||||
log("discord sign-in: " + why);
|
||||
state_ = State::Failed;
|
||||
}
|
||||
|
||||
void DiscordLink::run() {
|
||||
json reply;
|
||||
std::string error;
|
||||
if (!auth_call(auth_, "/auth/device/start", {{"DeviceName", device_name()}}, reply, error)) return fail(error);
|
||||
const std::string code = jstr(reply, "DeviceCode");
|
||||
int interval = static_cast<int>(jint(reply, "Interval", 5));
|
||||
if (interval < 1) interval = 5;
|
||||
const int expires = static_cast<int>(jint(reply, "ExpiresIn", 600));
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
user_code_ = jstr(reply, "UserCode");
|
||||
uri_ = jstr(reply, "VerificationUri");
|
||||
expires_at_ = now() + expires;
|
||||
if (code.empty() || user_code_.empty()) return fail("no code in the reply");
|
||||
}
|
||||
log("discord sign-in: approve code " + user_code() + " at " + uri());
|
||||
state_ = State::Waiting;
|
||||
while (!cancel_) {
|
||||
for (int i = 0; i < interval * 10 && !cancel_; ++i) Sleep(100);
|
||||
if (cancel_) return;
|
||||
if (seconds_left() <= 0) return fail("the code expired - start again");
|
||||
if (!auth_call(auth_, "/auth/device/poll", {{"DeviceCode", code}}, reply, error)) return fail(error);
|
||||
const std::string status = jstr(reply, "Status");
|
||||
if (status == "approved") {
|
||||
const std::string token = jstr(reply, "Token"), id = jstr(reply, "OnlineId");
|
||||
if (token.empty() || id.empty()) return fail("no token in the reply");
|
||||
if (!save(id, token)) return fail("could not write the account file");
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
online_id_ = id;
|
||||
}
|
||||
log("discord sign-in: linked, signed in as '" + id + "'");
|
||||
state_ = State::Approved;
|
||||
return;
|
||||
}
|
||||
if (status != "pending" && !status.empty()) return fail("the server says: " + status);
|
||||
}
|
||||
}
|
||||
|
||||
bool DiscordLink::save(const std::string& online_id, const std::string& token) {
|
||||
json state = json::object();
|
||||
{
|
||||
std::ifstream in(fspath(state_file_));
|
||||
if (in) {
|
||||
state = json::parse(in, nullptr, false);
|
||||
if (!state.is_object()) state = json::object();
|
||||
}
|
||||
}
|
||||
if (!state["accounts"].is_object()) state["accounts"] = json::object();
|
||||
// Keyed by the account's name, which becomes the NPID: the game signs in to
|
||||
// the game server under it, and the token's account matches.
|
||||
state["accounts"][online_id] = {{"bbhost_name", online_id}, {"token", token}, {"online_id", online_id},
|
||||
{"linked", "discord"}};
|
||||
const std::wstring tmp = state_file_ + L".tmp";
|
||||
{
|
||||
std::ofstream out(fspath(tmp), std::ios::trunc);
|
||||
if (!out) return false;
|
||||
out << state.dump(2);
|
||||
}
|
||||
return replace_file(tmp, state_file_);
|
||||
}
|
||||
|
||||
} // namespace bb
|
||||
@@ -0,0 +1,73 @@
|
||||
// Sign in with Discord: the OAuth device flow bbhost uses (src/net/account.cpp).
|
||||
// POST /auth/device/start {DeviceName} -> DeviceCode, UserCode, VerificationUri, Interval, ExpiresIn
|
||||
// POST /auth/device/poll {DeviceCode} -> Status pending | approved (+ Token, OnlineId) | ...
|
||||
// The player approves the code on The Hunter's Dream with their Discord account;
|
||||
// the account and its token then go into bbbridge_state.json under its name, and
|
||||
// the bridge signs in with that token when the game connects with that NPID.
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include "util.hpp"
|
||||
|
||||
namespace bb {
|
||||
|
||||
class DiscordLink {
|
||||
public:
|
||||
enum class State { Idle, Starting, Waiting, Approved, Failed };
|
||||
|
||||
DiscordLink(std::string auth_server, std::wstring state_file) : auth_(std::move(auth_server)), state_file_(std::move(state_file)) {}
|
||||
~DiscordLink() { cancel(); }
|
||||
|
||||
void start(); // runs in its own thread; read progress with the getters
|
||||
void cancel();
|
||||
|
||||
State state() const { return state_; }
|
||||
std::string user_code() const { std::lock_guard<std::mutex> lk(mu_); return user_code_; }
|
||||
std::string uri() const { std::lock_guard<std::mutex> lk(mu_); return uri_; }
|
||||
std::string online_id() const { std::lock_guard<std::mutex> lk(mu_); return online_id_; }
|
||||
std::string message() const { std::lock_guard<std::mutex> lk(mu_); return message_; }
|
||||
double seconds_left() const;
|
||||
|
||||
private:
|
||||
void run();
|
||||
void fail(const std::string& why);
|
||||
bool save(const std::string& online_id, const std::string& token);
|
||||
|
||||
std::string auth_;
|
||||
std::wstring state_file_;
|
||||
std::atomic<State> state_{State::Idle};
|
||||
std::atomic<bool> cancel_{false};
|
||||
mutable std::mutex mu_;
|
||||
std::string user_code_, uri_, online_id_, message_;
|
||||
double expires_at_ = 0;
|
||||
std::thread worker_;
|
||||
};
|
||||
|
||||
std::string device_name(); // "bbbridge on <COMPUTERNAME>"
|
||||
|
||||
// The website sign-in code for an account the bridge holds a token for
|
||||
// (thehuntersdream.com/account, "Sign in with a code from the game"):
|
||||
// POST /auth/web_code {Token} -> Code. Returns "" and sets error on failure.
|
||||
std::string website_code(const std::string& auth_server, const std::wstring& state_file, const std::string& npid,
|
||||
std::string& error);
|
||||
|
||||
// What the state file holds for this NPID: "discord", "bbbridge", or "" (none yet).
|
||||
std::string account_kind(const std::wstring& state_file, const std::string& npid);
|
||||
|
||||
// bbhost's other account buttons (src/net/account.cpp), on the same server:
|
||||
// create an account named `name` on this PC; recover one with its recovery code
|
||||
// (the server answers with a new code, which replaces the old one); sign out
|
||||
// (the token is ended on the server and removed here; a recovery code stays).
|
||||
// Each returns "" on success, else the reason.
|
||||
std::string account_create_now(const std::string& auth_server, const std::wstring& state_file, const std::string& name);
|
||||
std::string account_recover_now(const std::string& auth_server, const std::wstring& state_file, const std::string& name,
|
||||
const std::string& recovery_code);
|
||||
std::string account_sign_out(const std::string& auth_server, const std::wstring& state_file, const std::string& name);
|
||||
std::string recovery_code_of(const std::wstring& state_file, const std::string& name);
|
||||
|
||||
} // namespace bb
|
||||
@@ -0,0 +1,976 @@
|
||||
// bbbridge.exe: the bridge with a window. It sits in a folder inside bbport's
|
||||
// (E:\bloodborne\bbbridge\), keeps its config, account and log there, points
|
||||
// bbport at itself, and starts the game.
|
||||
#include "bridge.hpp" // first: winsock2.h before anything pulls in windows.h's winsock.h
|
||||
|
||||
#include <d3d11.h>
|
||||
#include <shellapi.h>
|
||||
#include <shlobj.h>
|
||||
#include <shobjidl.h>
|
||||
|
||||
#include <deque>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
|
||||
#include "account_link.hpp"
|
||||
#include "imgui.h"
|
||||
#include "imgui_impl_dx11.h"
|
||||
#include "imgui_impl_win32.h"
|
||||
#include "setup.hpp"
|
||||
|
||||
#pragma comment(lib, "d3d11.lib")
|
||||
|
||||
extern IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandler(HWND, UINT, WPARAM, LPARAM);
|
||||
|
||||
namespace {
|
||||
|
||||
using namespace bb;
|
||||
|
||||
// ---- D3D11 boilerplate (from Dear ImGui's example_win32_directx11) ----
|
||||
ID3D11Device* g_device = nullptr;
|
||||
ID3D11DeviceContext* g_context = nullptr;
|
||||
IDXGISwapChain* g_swap = nullptr;
|
||||
ID3D11RenderTargetView* g_rtv = nullptr;
|
||||
UINT g_resize_w = 0, g_resize_h = 0;
|
||||
bool g_close_requested = false;
|
||||
|
||||
void create_rtv() {
|
||||
ID3D11Texture2D* back = nullptr;
|
||||
g_swap->GetBuffer(0, IID_PPV_ARGS(&back));
|
||||
g_device->CreateRenderTargetView(back, nullptr, &g_rtv);
|
||||
back->Release();
|
||||
}
|
||||
|
||||
void release_rtv() {
|
||||
if (g_rtv) g_rtv->Release();
|
||||
g_rtv = nullptr;
|
||||
}
|
||||
|
||||
bool create_device(HWND hwnd) {
|
||||
DXGI_SWAP_CHAIN_DESC sd{};
|
||||
sd.BufferCount = 2;
|
||||
sd.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
|
||||
sd.BufferDesc.RefreshRate.Numerator = 60;
|
||||
sd.BufferDesc.RefreshRate.Denominator = 1;
|
||||
sd.Flags = DXGI_SWAP_CHAIN_FLAG_ALLOW_MODE_SWITCH;
|
||||
sd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
|
||||
sd.OutputWindow = hwnd;
|
||||
sd.SampleDesc.Count = 1;
|
||||
sd.Windowed = TRUE;
|
||||
sd.SwapEffect = DXGI_SWAP_EFFECT_DISCARD;
|
||||
const D3D_FEATURE_LEVEL levels[] = {D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_10_0};
|
||||
D3D_FEATURE_LEVEL got;
|
||||
HRESULT hr = D3D11CreateDeviceAndSwapChain(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, 0, levels, 2,
|
||||
D3D11_SDK_VERSION, &sd, &g_swap, &g_device, &got, &g_context);
|
||||
if (hr == DXGI_ERROR_UNSUPPORTED) // no GPU driver: the software rasterizer
|
||||
hr = D3D11CreateDeviceAndSwapChain(nullptr, D3D_DRIVER_TYPE_WARP, nullptr, 0, levels, 2, D3D11_SDK_VERSION,
|
||||
&sd, &g_swap, &g_device, &got, &g_context);
|
||||
if (FAILED(hr)) return false;
|
||||
create_rtv();
|
||||
return true;
|
||||
}
|
||||
|
||||
void destroy_device() {
|
||||
release_rtv();
|
||||
if (g_swap) g_swap->Release();
|
||||
if (g_context) g_context->Release();
|
||||
if (g_device) g_device->Release();
|
||||
}
|
||||
|
||||
LRESULT WINAPI wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
||||
if (ImGui_ImplWin32_WndProcHandler(hwnd, msg, wp, lp)) return TRUE;
|
||||
switch (msg) {
|
||||
case WM_SIZE:
|
||||
if (wp != SIZE_MINIMIZED) {
|
||||
g_resize_w = LOWORD(lp);
|
||||
g_resize_h = HIWORD(lp);
|
||||
}
|
||||
return 0;
|
||||
case WM_SYSCOMMAND:
|
||||
if ((wp & 0xfff0) == SC_KEYMENU) return 0;
|
||||
break;
|
||||
case WM_CLOSE:
|
||||
g_close_requested = true; // the app decides (the game may still be running)
|
||||
return 0;
|
||||
case WM_DESTROY:
|
||||
PostQuitMessage(0);
|
||||
return 0;
|
||||
}
|
||||
return DefWindowProcW(hwnd, msg, wp, lp);
|
||||
}
|
||||
|
||||
// ---- the app ----
|
||||
|
||||
fs::path exe_dir() {
|
||||
wchar_t buf[MAX_PATH];
|
||||
GetModuleFileNameW(nullptr, buf, MAX_PATH);
|
||||
return fs::path(buf).parent_path();
|
||||
}
|
||||
|
||||
// "" when started, else why not
|
||||
std::string launch(const fs::path& exe, const fs::path& cwd) {
|
||||
std::error_code ec;
|
||||
if (!fs::is_regular_file(exe, ec)) return narrow(exe.wstring()) + " is missing.";
|
||||
STARTUPINFOW si{};
|
||||
si.cb = sizeof(si);
|
||||
PROCESS_INFORMATION pi{};
|
||||
std::wstring cmd = L"\"" + exe.wstring() + L"\"";
|
||||
if (!CreateProcessW(exe.c_str(), cmd.data(), nullptr, nullptr, FALSE, 0, nullptr, cwd.c_str(), &si, &pi)) {
|
||||
const DWORD err = GetLastError();
|
||||
wchar_t* text = nullptr;
|
||||
FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
|
||||
nullptr, err, 0, reinterpret_cast<wchar_t*>(&text), 0, nullptr);
|
||||
std::string why = text ? narrow(text) : fmt("error %lu", err);
|
||||
if (text) LocalFree(text);
|
||||
while (!why.empty() && (why.back() == '\n' || why.back() == '\r' || why.back() == '.')) why.pop_back();
|
||||
return "Could not start " + narrow(exe.wstring()) + ": " + why + ".";
|
||||
}
|
||||
CloseHandle(pi.hThread);
|
||||
CloseHandle(pi.hProcess);
|
||||
return "";
|
||||
}
|
||||
|
||||
// A two-way switch: "left" label, the switch, "right" label; *right is the
|
||||
// state. The switch and either label change it. True when changed.
|
||||
bool network_switch(const char* id, const char* left, const char* right, bool* on) {
|
||||
const ImVec4 lit(0.95f, 0.95f, 0.95f, 1), dim(0.5f, 0.5f, 0.5f, 1);
|
||||
const ImVec4 left_track(0.22f, 0.42f, 0.62f, 1), right_track(0.62f, 0.16f, 0.16f, 1);
|
||||
bool changed = false;
|
||||
ImGui::PushID(id);
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::TextColored(*on ? dim : lit, "%s", left);
|
||||
if (ImGui::IsItemClicked() && *on) *on = false, changed = true;
|
||||
ImGui::SameLine();
|
||||
const float h = ImGui::GetFrameHeight(), w = h * 2.1f;
|
||||
const ImVec2 p = ImGui::GetCursorScreenPos();
|
||||
if (ImGui::InvisibleButton("switch", ImVec2(w, h))) *on = !*on, changed = true;
|
||||
const bool hovered = ImGui::IsItemHovered();
|
||||
// slide the knob over ~0.12 s
|
||||
ImGuiStorage* store = ImGui::GetStateStorage();
|
||||
const ImGuiID key = ImGui::GetID("t");
|
||||
float t = store->GetFloat(key, *on ? 1.0f : 0.0f);
|
||||
const float step = ImGui::GetIO().DeltaTime / 0.12f;
|
||||
t = *on ? (t + step > 1 ? 1 : t + step) : (t - step < 0 ? 0 : t - step);
|
||||
store->SetFloat(key, t);
|
||||
ImVec4 track(left_track.x + (right_track.x - left_track.x) * t, left_track.y + (right_track.y - left_track.y) * t,
|
||||
left_track.z + (right_track.z - left_track.z) * t, 1);
|
||||
if (hovered) track = ImVec4(track.x * 1.15f, track.y * 1.15f, track.z * 1.15f, 1);
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
dl->AddRectFilled(p, ImVec2(p.x + w, p.y + h), ImGui::GetColorU32(track), h * 0.5f);
|
||||
const float r = h * 0.5f - 3;
|
||||
dl->AddCircleFilled(ImVec2(p.x + r + 3 + t * (w - 2 * r - 6), p.y + h * 0.5f), r, ImGui::GetColorU32(lit));
|
||||
ImGui::SameLine();
|
||||
ImGui::TextColored(*on ? lit : dim, "%s", right);
|
||||
if (ImGui::IsItemClicked() && !*on) *on = true, changed = true;
|
||||
ImGui::PopID();
|
||||
return changed;
|
||||
}
|
||||
|
||||
std::optional<fs::path> pick_folder(HWND owner) {
|
||||
std::optional<fs::path> out;
|
||||
IFileOpenDialog* dlg = nullptr;
|
||||
if (FAILED(CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_ALL, IID_PPV_ARGS(&dlg)))) return out;
|
||||
DWORD opts = 0;
|
||||
dlg->GetOptions(&opts);
|
||||
dlg->SetOptions(opts | FOS_PICKFOLDERS);
|
||||
dlg->SetTitle(L"bbport folder (the one with Bloodborne.exe)");
|
||||
if (SUCCEEDED(dlg->Show(owner))) {
|
||||
IShellItem* item = nullptr;
|
||||
if (SUCCEEDED(dlg->GetResult(&item))) {
|
||||
PWSTR path = nullptr;
|
||||
if (SUCCEEDED(item->GetDisplayName(SIGDN_FILESYSPATH, &path))) {
|
||||
out = fs::path(path);
|
||||
CoTaskMemFree(path);
|
||||
}
|
||||
item->Release();
|
||||
}
|
||||
}
|
||||
dlg->Release();
|
||||
return out;
|
||||
}
|
||||
|
||||
class App {
|
||||
public:
|
||||
explicit App(HWND hwnd) : hwnd_(hwnd), dir_(exe_dir()) {
|
||||
set_log_file((dir_ / L"bbbridge.log").wstring());
|
||||
set_log_sink([this](const std::string& line) {
|
||||
std::lock_guard<std::mutex> lk(log_mu_);
|
||||
lines_.push_back(line);
|
||||
while (lines_.size() > 3000) lines_.pop_front();
|
||||
scroll_ = true;
|
||||
});
|
||||
load_config();
|
||||
reload_setup();
|
||||
log(fmt("bbbridge %s - bbport: %s (%s)", kVersion, bbport_dir_.empty() ? "-" : narrow(bbport_dir_.wstring()).c_str(),
|
||||
describe(bbport_source_).c_str()));
|
||||
if (!setup_error_.empty()) log(setup_error_);
|
||||
check_folder_on_start();
|
||||
if (setup_ && setup_->routed()) start_bridge(); // routed already: be ready when the game starts
|
||||
}
|
||||
|
||||
~App() {
|
||||
stop_bridge(); // and the game's server traffic goes back to bbport's own addresses
|
||||
}
|
||||
|
||||
void frame();
|
||||
bool wants_close() {
|
||||
if (!g_close_requested) return false;
|
||||
if (game_running_ && bridge_) {
|
||||
confirm_close_ = true;
|
||||
g_close_requested = false;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
void load_config();
|
||||
void save_config();
|
||||
void try_setup();
|
||||
void reload_setup();
|
||||
void refresh();
|
||||
void check_folder_on_start();
|
||||
void choose_folder();
|
||||
void start_bridge();
|
||||
void stop_bridge();
|
||||
void play();
|
||||
void open_launcher();
|
||||
void fail(const std::string& message);
|
||||
void status_panel();
|
||||
void setup_panel();
|
||||
void account_row();
|
||||
void account_popups();
|
||||
void message_popups();
|
||||
void log_panel();
|
||||
void use_npid(const std::string& npid);
|
||||
void apply_defaults();
|
||||
void sync_overrides();
|
||||
bool live_map_wanted() const { return jint(config_, "live_map", 1) != 0; }
|
||||
bool route_wanted() const { return jint(config_, "route", 1) != 0; } // the box, as last ticked; bbhost by default
|
||||
bool signed_in() const { return !account_.empty() && !account_kind(state_path(), account_).empty(); }
|
||||
std::wstring state_path() const { return (dir_ / L"bbbridge_state.json").wstring(); }
|
||||
std::string auth_server() const { return jstr(config_, "auth_server", "https://thehuntersdream.com"); }
|
||||
|
||||
HWND hwnd_;
|
||||
fs::path dir_;
|
||||
json config_ = json::object();
|
||||
std::unique_ptr<Bridge> bridge_;
|
||||
std::optional<BbportSetup> setup_;
|
||||
std::string setup_error_;
|
||||
fs::path chosen_bbport_;
|
||||
fs::path bbport_dir_; // known even when setup_ is not (no launcher settings yet)
|
||||
BbportSource bbport_source_ = BbportSource::None;
|
||||
// The Hunter's Dream account (bbbridge_config.json "account"). It is bbport's
|
||||
// online id only while routed: unrouted, bbport keeps its own shadNet account.
|
||||
std::string account_;
|
||||
bool launcher_running_ = false, game_running_ = false, routed_ = false, patched_ = false;
|
||||
double last_refresh_ = 0;
|
||||
std::string error_, folder_question_;
|
||||
bool open_error_ = false, open_folder_ = false;
|
||||
bool confirm_close_ = false;
|
||||
std::unique_ptr<DiscordLink> link_;
|
||||
bool open_link_ = false, open_web_ = false;
|
||||
std::string web_code_, web_error_;
|
||||
std::mutex log_mu_;
|
||||
std::deque<std::string> lines_;
|
||||
bool scroll_ = false;
|
||||
};
|
||||
|
||||
void App::load_config() {
|
||||
std::ifstream in(dir_ / L"bbbridge_config.json");
|
||||
if (in) {
|
||||
json j = json::parse(in, nullptr, false);
|
||||
if (j.is_object()) config_ = j;
|
||||
}
|
||||
if (jhas(config_, "bbport_dir")) chosen_bbport_ = fs::path(widen(jstr(config_, "bbport_dir")));
|
||||
account_ = jstr(config_, "account");
|
||||
}
|
||||
|
||||
void App::save_config() {
|
||||
std::ofstream(dir_ / L"bbbridge_config.json", std::ios::trunc) << config_.dump(2) << "\n";
|
||||
}
|
||||
|
||||
void App::try_setup() {
|
||||
try {
|
||||
setup_.emplace(dir_, chosen_bbport_);
|
||||
setup_error_.clear();
|
||||
bbport_dir_ = setup_->bbport;
|
||||
bbport_source_ = setup_->source;
|
||||
// older bbbridge kept the account only as bbport's online id
|
||||
if (account_.empty() && !account_kind(state_path(), setup_->npid()).empty()) {
|
||||
account_ = setup_->npid();
|
||||
config_["account"] = account_;
|
||||
save_config();
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
setup_.reset();
|
||||
setup_error_ = e.what();
|
||||
bbport_dir_ = find_bbport(dir_, chosen_bbport_, "", &bbport_source_); // still lets "bbport settings..." work
|
||||
}
|
||||
}
|
||||
|
||||
void App::reload_setup() {
|
||||
try_setup();
|
||||
refresh();
|
||||
}
|
||||
|
||||
// The first look: say where bbport is unless bbbridge sits inside it (the
|
||||
// normal install), and ask when it had to guess or a chosen folder is gone.
|
||||
void App::check_folder_on_start() {
|
||||
const std::string here = narrow(bbport_dir_.wstring());
|
||||
if (bbport_source_ == BbportSource::None) {
|
||||
folder_question_ = "bbport was not found.\n\nPut the bbbridge folder inside bbport's folder (the one with "
|
||||
"Bloodborne.exe), e.g. bloodborne\\bbbridge\\bbbridge.exe, or choose the bbport folder.";
|
||||
} else if (!chosen_bbport_.empty() && bbport_source_ != BbportSource::Chosen) {
|
||||
folder_question_ = "The bbport folder you chose before has no Bloodborne.exe any more:\n " +
|
||||
narrow(chosen_bbport_.wstring()) + "\n\nbbbridge found bbport here instead (" +
|
||||
describe(bbport_source_) + "):\n " + here + "\n\nUse this folder?";
|
||||
} else if (bbport_source_ == BbportSource::Launcher) {
|
||||
folder_question_ = "bbbridge is not inside a bbport folder. The bbport launcher points to this one:\n " + here +
|
||||
"\n\nUse this folder?";
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
open_folder_ = true;
|
||||
}
|
||||
|
||||
void App::choose_folder() {
|
||||
auto p = pick_folder(hwnd_);
|
||||
if (!p) return;
|
||||
if (!is_bbport_dir(*p)) {
|
||||
fail(narrow(p->wstring()) + " is not a bbport folder: it has no Bloodborne.exe or Play Bloodborne.exe.\n\n"
|
||||
"Choose the folder that has Bloodborne.exe in it.");
|
||||
return;
|
||||
}
|
||||
chosen_bbport_ = *p;
|
||||
config_["bbport_dir"] = narrow(p->wstring());
|
||||
save_config();
|
||||
reload_setup();
|
||||
log("bbport folder: " + narrow(p->wstring()));
|
||||
if (!setup_error_.empty()) fail(setup_error_);
|
||||
}
|
||||
|
||||
void App::fail(const std::string& message) {
|
||||
log("error: " + message);
|
||||
error_ = message;
|
||||
open_error_ = true;
|
||||
}
|
||||
|
||||
void App::refresh() {
|
||||
launcher_running_ = launcher_running();
|
||||
game_running_ = game_running();
|
||||
if (!setup_ && !launcher_running_ && bbport_source_ != BbportSource::None) try_setup(); // e.g. the launcher just made its settings
|
||||
if (setup_) {
|
||||
try {
|
||||
if (!launcher_running_) setup_->reload(); // the launcher may have saved new settings
|
||||
} catch (...) {
|
||||
}
|
||||
routed_ = setup_->routed();
|
||||
patched_ = setup_->patched();
|
||||
if (!jint(config_, "defaults_applied", 0) && !launcher_running_ && !game_running_ && signed_in())
|
||||
apply_defaults();
|
||||
sync_overrides();
|
||||
}
|
||||
last_refresh_ = now();
|
||||
}
|
||||
|
||||
// The game's server traffic goes to bbbridge (host_overrides.custom.json) exactly
|
||||
// while bbport is switched to The Hunter's Dream and the bridge runs: with
|
||||
// bbbridge closed, or bbport set back to shadNet in its own launcher, the file
|
||||
// would leave the game unable to reach its server.
|
||||
void App::sync_overrides() {
|
||||
if (!setup_) return;
|
||||
const bool want = setup_->routed() && bridge_; // live: PLAY routes and starts the bridge in one go
|
||||
if (want == setup_->overrides_written()) return;
|
||||
try {
|
||||
setup_->set_overrides(want);
|
||||
log(want ? "the game's server traffic goes through bbbridge"
|
||||
: "the game's server traffic goes to bbport's own addresses again");
|
||||
} catch (const std::exception& e) {
|
||||
log(std::string("host_overrides.custom.json: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
// Once, when there is an account to play with: route bbport through bbbridge
|
||||
// and add the live map patch, so starting from the bbport launcher works too
|
||||
// and nobody has to remember them. Switching them off later sticks.
|
||||
void App::apply_defaults() {
|
||||
try {
|
||||
if (!routed_) setup_->enable();
|
||||
if (setup_->npid() != account_) setup_->set_npid(account_);
|
||||
if (live_map_wanted() && !patched_) setup_->set_patch(true);
|
||||
} catch (const std::exception& e) {
|
||||
fail(std::string("Could not set bbport up: ") + e.what());
|
||||
return;
|
||||
}
|
||||
config_["defaults_applied"] = true;
|
||||
config_["route"] = true;
|
||||
save_config();
|
||||
routed_ = setup_->routed();
|
||||
patched_ = setup_->patched();
|
||||
log("bbport now uses bbbridge" + std::string(patched_ ? ", with the live map patch" : ""));
|
||||
if (routed_) start_bridge();
|
||||
}
|
||||
|
||||
void App::start_bridge() {
|
||||
if (bridge_) return;
|
||||
Config cfg = Config::from_json(config_);
|
||||
cfg.state_file = narrow((dir_ / L"bbbridge_state.json").wstring()); // the account, next to the exe
|
||||
auto b = std::make_unique<Bridge>(cfg);
|
||||
std::string error;
|
||||
if (!b->start(&error)) {
|
||||
fail("The bridge could not start: " + error);
|
||||
return;
|
||||
}
|
||||
bridge_ = std::move(b);
|
||||
sync_overrides();
|
||||
}
|
||||
|
||||
void App::stop_bridge() {
|
||||
if (!bridge_) return;
|
||||
bridge_->stop();
|
||||
bridge_.reset();
|
||||
log("bridge stopped");
|
||||
sync_overrides();
|
||||
}
|
||||
|
||||
void App::play() {
|
||||
refresh();
|
||||
if (!setup_) {
|
||||
fail(setup_error_);
|
||||
return;
|
||||
}
|
||||
const bool via_bridge = route_wanted();
|
||||
if (via_bridge && !signed_in()) {
|
||||
fail("Not signed in. Press 'Sign in with Discord...' first - The Hunter's Dream needs your own account.\n\n"
|
||||
"(To play in bbport's own shadNet pool instead, move the Online switch to shadNet.)");
|
||||
return;
|
||||
}
|
||||
if (launcher_running_) {
|
||||
fail("Close the bbport launcher first - it would undo bbbridge's settings when it saves.");
|
||||
return;
|
||||
}
|
||||
if (!setup_->game_ready()) {
|
||||
fail("bbport has no game folder yet. Press 'bbport settings...', choose your Bloodborne folder "
|
||||
"(CUSA03173, the one with eboot.bin), close the launcher, then press PLAY.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (via_bridge) {
|
||||
if (!routed_) setup_->enable();
|
||||
if (setup_->npid() != account_) setup_->set_npid(account_);
|
||||
if (live_map_wanted() && !patched_) setup_->set_patch(true);
|
||||
} else if (routed_) {
|
||||
setup_->disable(); // e.g. the launcher was open when the switch moved
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
fail(std::string("Could not set bbport up: ") + e.what());
|
||||
return;
|
||||
}
|
||||
if (!via_bridge && setup_->online() && !setup_->shadnet_account_set()) {
|
||||
fail("bbport's online play is on but it has no shadNet account.\n\nRegister one at "
|
||||
"https://shadnet.shadps4.net, then press 'bbport settings...' > Online co-op and enter the account "
|
||||
"name (not the email) and password. Or move the Online switch to The Hunter's Dream "
|
||||
"to play with bbhost players using your Discord sign-in.");
|
||||
return;
|
||||
}
|
||||
if (via_bridge) {
|
||||
start_bridge();
|
||||
if (!bridge_) return;
|
||||
}
|
||||
if (const std::string err = launch(setup_->play_exe(), setup_->bbport); !err.empty()) {
|
||||
fail(err);
|
||||
return;
|
||||
}
|
||||
log(fmt("starting the game (%s): %s", via_bridge ? "bbhost pool through bbbridge" : "bbport's own shadNet",
|
||||
narrow(setup_->play_exe().wstring()).c_str()));
|
||||
refresh();
|
||||
}
|
||||
|
||||
void App::open_launcher() {
|
||||
refresh();
|
||||
if (bbport_dir_.empty()) {
|
||||
fail("bbport was not found. Press 'Choose bbport folder...' and pick the folder with Bloodborne.exe.");
|
||||
return;
|
||||
}
|
||||
if (launcher_running_) {
|
||||
fail("The bbport launcher is already open.");
|
||||
return;
|
||||
}
|
||||
if (game_running_) {
|
||||
fail("The game is running - close it before changing bbport's settings.");
|
||||
return;
|
||||
}
|
||||
if (const std::string err = launch(bbport_dir_ / L"Bloodborne.exe", bbport_dir_); !err.empty()) fail(err);
|
||||
}
|
||||
|
||||
void App::status_panel() {
|
||||
const BridgeStatus st = bridge_ ? bridge_->status() : BridgeStatus{};
|
||||
const ImVec4 green(0.35f, 0.85f, 0.45f, 1), grey(0.6f, 0.6f, 0.6f, 1), amber(0.95f, 0.75f, 0.3f, 1);
|
||||
ImGui::TextColored(bridge_ ? green : grey, bridge_ ? " Bridge running" : " Bridge stopped");
|
||||
if (bridge_) {
|
||||
ImGui::SameLine();
|
||||
if (st.relay_port)
|
||||
ImGui::TextDisabled(" relay port %d%s", st.relay_port,
|
||||
st.mapped ? (" - seen as " + st.mapped->str()).c_str() : "");
|
||||
else
|
||||
ImGui::TextDisabled(" waiting for The Hunter's Dream's STUN answer");
|
||||
}
|
||||
if (bridge_) {
|
||||
if (st.logged_in) ImGui::Text("Signed in as %s", st.npid.c_str());
|
||||
else if (!st.npid.empty()) ImGui::TextColored(amber, "Signing in as %s ...", st.npid.c_str());
|
||||
else ImGui::TextDisabled("Waiting for the game to connect");
|
||||
if (st.room_id) ImGui::Text("Room %llu - %d member(s)", static_cast<unsigned long long>(st.room_id), st.room_members);
|
||||
if (!st.peers.empty() && ImGui::BeginTable("peers", 4, ImGuiTableFlags_BordersInnerH | ImGuiTableFlags_SizingStretchProp)) {
|
||||
ImGui::TableSetupColumn("Player");
|
||||
ImGui::TableSetupColumn("Member");
|
||||
ImGui::TableSetupColumn("Packets out");
|
||||
ImGui::TableSetupColumn("Packets in");
|
||||
ImGui::TableHeadersRow();
|
||||
for (const auto& p : st.peers) {
|
||||
ImGui::TableNextRow();
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::TextUnformatted(p.online_id.c_str());
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::Text("%d", p.member_id);
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::Text("%llu", static_cast<unsigned long long>(p.to_peer));
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::Text("%llu", static_cast<unsigned long long>(p.to_port));
|
||||
}
|
||||
ImGui::EndTable();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void App::setup_panel() {
|
||||
ImGui::SeparatorText("Setup");
|
||||
if (!bbport_dir_.empty()) {
|
||||
ImGui::Text("bbport:");
|
||||
ImGui::SameLine();
|
||||
ImGui::TextUnformatted(narrow(bbport_dir_.wstring()).c_str());
|
||||
ImGui::SameLine();
|
||||
ImGui::TextDisabled("(%s)", describe(bbport_source_).c_str());
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("Change...")) choose_folder();
|
||||
}
|
||||
if (!setup_) {
|
||||
ImGui::PushTextWrapPos(0);
|
||||
ImGui::TextColored(ImVec4(0.95f, 0.5f, 0.4f, 1), "%s", setup_error_.c_str());
|
||||
ImGui::PopTextWrapPos();
|
||||
if (bbport_dir_.empty() && ImGui::Button("Choose bbport folder...")) choose_folder();
|
||||
return;
|
||||
}
|
||||
if (!setup_->game_ready())
|
||||
ImGui::TextColored(ImVec4(0.95f, 0.75f, 0.3f, 1),
|
||||
"Choose your game folder (CUSA03173, with eboot.bin) in the bbport launcher first.");
|
||||
account_row();
|
||||
ImGui::BeginDisabled(launcher_running_);
|
||||
bool routed = routed_;
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::TextUnformatted("Online:");
|
||||
ImGui::SameLine();
|
||||
if (network_switch("network", "shadNet", "The Hunter's Dream (bbhost)", &routed)) {
|
||||
try {
|
||||
if (routed) {
|
||||
setup_->enable();
|
||||
if (signed_in() && setup_->npid() != account_) setup_->set_npid(account_);
|
||||
start_bridge(); // the game's server traffic needs it running
|
||||
} else {
|
||||
setup_->disable(); // bbport's own shadNet server and account come back
|
||||
}
|
||||
config_["route"] = routed;
|
||||
save_config();
|
||||
log(routed ? "bbport now uses bbbridge" : "bbport back on its own shadNet server and account");
|
||||
} catch (const std::exception& e) {
|
||||
fail(e.what());
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip("Right: bbbridge connects bbport to The Hunter's Dream (bbhost players, Discord sign-in).\n"
|
||||
"Left: bbport's own shadNet server and account, without bbbridge.");
|
||||
ImGui::Indent();
|
||||
if (routed_)
|
||||
ImGui::TextDisabled(signed_in() ? "Co-op with bbhost players through bbbridge, as '%s'."
|
||||
: "Co-op with bbhost players through bbbridge - sign in with Discord first.",
|
||||
account_.c_str());
|
||||
else if (!setup_->online())
|
||||
ImGui::TextDisabled("bbport's own settings - online play is off there.");
|
||||
else if (setup_->shadnet_account_set())
|
||||
ImGui::TextDisabled("bbport's own shadNet pool (%s), as '%s'.", setup_->server().c_str(),
|
||||
setup_->npid().c_str());
|
||||
else
|
||||
ImGui::TextColored(ImVec4(0.95f, 0.75f, 0.3f, 1),
|
||||
"shadNet needs a shadNet account (shadnet.shadps4.net), entered in\n"
|
||||
"bbport settings > Online co-op.");
|
||||
ImGui::Unindent();
|
||||
bool patched = patched_;
|
||||
if (ImGui::Checkbox("Live map patch (show me on thehuntersdream.com's map)", &patched)) {
|
||||
setup_->set_patch(patched);
|
||||
config_["live_map"] = patched; // PLAY keeps to it
|
||||
save_config();
|
||||
refresh();
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
if (launcher_running_)
|
||||
ImGui::TextColored(ImVec4(0.95f, 0.75f, 0.3f, 1), "The bbport launcher is open - close it to change these.");
|
||||
bool trace = jint(config_, "trace", 0) != 0;
|
||||
if (ImGui::Checkbox("Detailed log (for bug reports; applies when the bridge restarts)", &trace)) {
|
||||
config_["trace"] = trace;
|
||||
save_config();
|
||||
}
|
||||
}
|
||||
|
||||
void App::use_npid(const std::string& npid) {
|
||||
account_ = npid;
|
||||
config_["account"] = npid;
|
||||
save_config();
|
||||
// bbport's online id changes only while it plays through bbbridge (PLAY sets it too)
|
||||
if (!setup_ || !routed_ || launcher_running_) return;
|
||||
try {
|
||||
setup_->set_npid(npid);
|
||||
log(fmt("online id set to '%s'", npid.c_str()));
|
||||
} catch (const std::exception& e) {
|
||||
fail(e.what());
|
||||
}
|
||||
}
|
||||
|
||||
void App::account_row() {
|
||||
// Everyone signs in with their own Discord account on The Hunter's Dream;
|
||||
// the account's name is bbport's online id (NPID) while routed.
|
||||
const std::string kind = account_.empty() ? "" : account_kind(state_path(), account_);
|
||||
if (!kind.empty()) {
|
||||
ImGui::TextColored(ImVec4(0.55f, 0.65f, 1.0f, 1), "Signed in as '%s'%s", account_.c_str(),
|
||||
kind == "discord" ? " (Discord)" : " (bbbridge account)");
|
||||
ImGui::BeginDisabled(launcher_running_);
|
||||
if (ImGui::Button("Website sign-in code")) {
|
||||
web_code_ = website_code(auth_server(), state_path(), account_, web_error_);
|
||||
open_web_ = true;
|
||||
}
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip("A code for thehuntersdream.com/account, 'Sign in with a code from the game'.");
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Switch account...")) {
|
||||
link_ = std::make_unique<DiscordLink>(auth_server(), state_path());
|
||||
link_->start();
|
||||
open_link_ = true;
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Sign out")) {
|
||||
const std::string err = account_sign_out(auth_server(), state_path(), account_);
|
||||
if (!err.empty()) {
|
||||
fail("Sign out failed: " + err);
|
||||
} else {
|
||||
account_.clear();
|
||||
config_.erase("account");
|
||||
save_config();
|
||||
}
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
return;
|
||||
}
|
||||
ImGui::TextColored(ImVec4(0.95f, 0.75f, 0.3f, 1), "Not signed in. Sign in with your Discord account to play online.");
|
||||
ImGui::BeginDisabled(launcher_running_);
|
||||
if (ImGui::Button("Sign in with Discord...", ImVec2(220, 0))) {
|
||||
link_ = std::make_unique<DiscordLink>(auth_server(), state_path());
|
||||
link_->start();
|
||||
open_link_ = true;
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
ImGui::SameLine();
|
||||
ImGui::TextDisabled("opens The Hunter's Dream: sign in there with Discord and approve the code");
|
||||
}
|
||||
|
||||
void App::account_popups() {
|
||||
if (open_link_) {
|
||||
ImGui::OpenPopup("Sign in with Discord");
|
||||
open_link_ = false;
|
||||
}
|
||||
if (ImGui::BeginPopupModal("Sign in with Discord", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
const DiscordLink::State st = link_ ? link_->state() : DiscordLink::State::Idle;
|
||||
if (st == DiscordLink::State::Starting) {
|
||||
ImGui::TextUnformatted("Asking The Hunter's Dream for a code ...");
|
||||
} else if (st == DiscordLink::State::Waiting) {
|
||||
ImGui::TextUnformatted("1. Open the sign-in page and sign in with Discord.");
|
||||
ImGui::TextUnformatted("2. Enter this code there:");
|
||||
ImGui::SetWindowFontScale(2.2f);
|
||||
ImGui::TextColored(ImVec4(0.95f, 0.85f, 0.5f, 1), " %s", link_->user_code().c_str());
|
||||
ImGui::SetWindowFontScale(1.0f);
|
||||
ImGui::TextDisabled("%s", link_->uri().c_str());
|
||||
if (ImGui::Button("Open sign-in page"))
|
||||
ShellExecuteW(nullptr, L"open", widen(link_->uri()).c_str(), nullptr, nullptr, SW_SHOWNORMAL);
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Copy code")) ImGui::SetClipboardText(link_->user_code().c_str());
|
||||
ImGui::TextDisabled("Waiting for the approval ... (%d s left)", static_cast<int>(link_->seconds_left()));
|
||||
} else if (st == DiscordLink::State::Approved) {
|
||||
ImGui::Text("Signed in as '%s'.", link_->online_id().c_str());
|
||||
ImGui::TextUnformatted("bbport will use this account as its online id.");
|
||||
} else if (st == DiscordLink::State::Failed) {
|
||||
ImGui::TextColored(ImVec4(0.95f, 0.5f, 0.4f, 1), "Sign-in failed: %s", link_->message().c_str());
|
||||
}
|
||||
ImGui::Separator();
|
||||
if (st == DiscordLink::State::Approved) {
|
||||
if (ImGui::Button("Done")) {
|
||||
use_npid(link_->online_id());
|
||||
link_.reset();
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
} else {
|
||||
if (st == DiscordLink::State::Failed && ImGui::Button("Try again")) link_->start();
|
||||
if (st == DiscordLink::State::Failed) ImGui::SameLine();
|
||||
if (ImGui::Button("Cancel")) {
|
||||
link_.reset();
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
if (open_web_) {
|
||||
ImGui::OpenPopup("Website sign-in code");
|
||||
open_web_ = false;
|
||||
}
|
||||
if (ImGui::BeginPopupModal("Website sign-in code", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
if (web_code_.empty()) {
|
||||
ImGui::TextColored(ImVec4(0.95f, 0.5f, 0.4f, 1), "No code: %s", web_error_.c_str());
|
||||
} else {
|
||||
ImGui::Text("For thehuntersdream.com/account, 'Sign in with a code from the game' (account '%s'):",
|
||||
account_.c_str());
|
||||
ImGui::SetWindowFontScale(2.2f);
|
||||
ImGui::TextColored(ImVec4(0.95f, 0.85f, 0.5f, 1), " %s", web_code_.c_str());
|
||||
ImGui::SetWindowFontScale(1.0f);
|
||||
ImGui::TextDisabled("Valid for a few minutes.");
|
||||
if (ImGui::Button("Copy code")) ImGui::SetClipboardText(web_code_.c_str());
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Open the account page"))
|
||||
ShellExecuteW(nullptr, L"open", widen(auth_server() + "/account").c_str(), nullptr, nullptr, SW_SHOWNORMAL);
|
||||
}
|
||||
ImGui::Separator();
|
||||
if (ImGui::Button("Close")) ImGui::CloseCurrentPopup();
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
}
|
||||
|
||||
void App::message_popups() {
|
||||
const float wrap = ImGui::GetFontSize() * 34;
|
||||
if (open_folder_) {
|
||||
ImGui::OpenPopup("bbport folder");
|
||||
open_folder_ = false;
|
||||
}
|
||||
if (ImGui::BeginPopupModal("bbport folder", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
ImGui::PushTextWrapPos(wrap);
|
||||
ImGui::TextUnformatted(folder_question_.c_str());
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::Separator();
|
||||
if (!bbport_dir_.empty() && ImGui::Button("Use this folder")) {
|
||||
if (bbport_source_ == BbportSource::ExeFolder) { // the normal place: nothing to remember
|
||||
chosen_bbport_.clear();
|
||||
config_.erase("bbport_dir");
|
||||
} else {
|
||||
chosen_bbport_ = bbport_dir_;
|
||||
config_["bbport_dir"] = narrow(bbport_dir_.wstring());
|
||||
}
|
||||
save_config();
|
||||
reload_setup();
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
if (!bbport_dir_.empty()) ImGui::SameLine();
|
||||
if (ImGui::Button("Choose another folder...")) {
|
||||
ImGui::CloseCurrentPopup();
|
||||
choose_folder();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Close")) ImGui::CloseCurrentPopup();
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
if (open_error_) {
|
||||
ImGui::OpenPopup("bbbridge###error");
|
||||
open_error_ = false;
|
||||
}
|
||||
if (ImGui::BeginPopupModal("bbbridge###error", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
ImGui::PushTextWrapPos(wrap);
|
||||
ImGui::TextColored(ImVec4(0.95f, 0.6f, 0.5f, 1), "%s", error_.c_str());
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::Separator();
|
||||
if (ImGui::Button("OK", ImVec2(100, 0)) || ImGui::IsKeyPressed(ImGuiKey_Enter)) ImGui::CloseCurrentPopup();
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
}
|
||||
|
||||
void App::log_panel() {
|
||||
ImGui::SeparatorText("Log");
|
||||
if (ImGui::SmallButton("Copy log")) {
|
||||
std::string all;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(log_mu_);
|
||||
for (const auto& l : lines_) all += l + "\n";
|
||||
}
|
||||
ImGui::SetClipboardText(all.c_str());
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("Open folder")) ShellExecuteW(nullptr, L"open", dir_.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
|
||||
ImGui::BeginChild("log", ImVec2(0, 0), ImGuiChildFlags_Borders, ImGuiWindowFlags_HorizontalScrollbar);
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(log_mu_);
|
||||
ImGuiListClipper clip;
|
||||
clip.Begin(static_cast<int>(lines_.size()));
|
||||
while (clip.Step())
|
||||
for (int i = clip.DisplayStart; i < clip.DisplayEnd; ++i) ImGui::TextUnformatted(lines_[i].c_str());
|
||||
if (scroll_ && ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 40) ImGui::SetScrollHereY(1.0f);
|
||||
scroll_ = false;
|
||||
}
|
||||
ImGui::EndChild();
|
||||
}
|
||||
|
||||
void App::frame() {
|
||||
if (now() - last_refresh_ > 1.5) refresh();
|
||||
const ImGuiViewport* vp = ImGui::GetMainViewport();
|
||||
ImGui::SetNextWindowPos(vp->WorkPos);
|
||||
ImGui::SetNextWindowSize(vp->WorkSize);
|
||||
ImGui::Begin("bbbridge", nullptr,
|
||||
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings |
|
||||
ImGuiWindowFlags_NoBringToFrontOnFocus);
|
||||
ImGui::Text("bbbridge %s", kVersion);
|
||||
ImGui::SameLine();
|
||||
ImGui::TextDisabled("- co-op with bbhost players on The Hunter's Dream");
|
||||
ImGui::Spacing();
|
||||
status_panel();
|
||||
ImGui::Spacing();
|
||||
setup_panel();
|
||||
ImGui::Spacing();
|
||||
|
||||
// PLAY and bbport settings stay clickable: when something is missing they say what.
|
||||
ImGui::BeginDisabled(game_running_);
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.55f, 0.12f, 0.12f, 1));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.16f, 0.16f, 1));
|
||||
if (ImGui::Button(game_running_ ? " PLAYING " : " PLAY ", ImVec2(160, 40))) play();
|
||||
ImGui::PopStyleColor(2);
|
||||
ImGui::EndDisabled();
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button(bridge_ ? "Stop bridge" : "Start bridge", ImVec2(130, 40))) {
|
||||
if (bridge_) stop_bridge();
|
||||
else start_bridge();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("bbport settings...", ImVec2(160, 40))) open_launcher();
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip("Opens the bbport launcher for graphics, mods and cheats. Close it before pressing PLAY here.");
|
||||
ImGui::Spacing();
|
||||
log_panel();
|
||||
|
||||
account_popups();
|
||||
message_popups();
|
||||
if (confirm_close_) ImGui::OpenPopup("Close bbbridge?");
|
||||
if (ImGui::BeginPopupModal("Close bbbridge?", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
ImGui::TextUnformatted("The game is still running. Closing bbbridge takes it offline.");
|
||||
if (ImGui::Button("Close anyway")) {
|
||||
confirm_close_ = false;
|
||||
g_close_requested = true;
|
||||
game_running_ = false;
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Cancel")) {
|
||||
confirm_close_ = false;
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int WINAPI wWinMain(HINSTANCE inst, HINSTANCE, PWSTR, int) {
|
||||
// One bbbridge at a time: a second would fight the first over the ports.
|
||||
HANDLE single = CreateMutexW(nullptr, TRUE, L"Local\\bbbridge-app");
|
||||
if (GetLastError() == ERROR_ALREADY_EXISTS) {
|
||||
if (HWND other = FindWindowW(L"bbbridge", nullptr)) SetForegroundWindow(other);
|
||||
return 0;
|
||||
}
|
||||
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
net_init();
|
||||
ImGui_ImplWin32_EnableDpiAwareness();
|
||||
WNDCLASSEXW wc{sizeof(wc), CS_CLASSDC, wnd_proc, 0, 0, inst, LoadIconW(inst, MAKEINTRESOURCEW(1)), nullptr,
|
||||
nullptr, nullptr, L"bbbridge", nullptr};
|
||||
RegisterClassExW(&wc);
|
||||
const float scale = ImGui_ImplWin32_GetDpiScaleForMonitor(MonitorFromPoint(POINT{0, 0}, MONITOR_DEFAULTTOPRIMARY));
|
||||
HWND hwnd = CreateWindowW(wc.lpszClassName, L"bbbridge", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT,
|
||||
static_cast<int>(820 * scale), static_cast<int>(760 * scale), nullptr, nullptr, inst, nullptr);
|
||||
if (!create_device(hwnd)) {
|
||||
MessageBoxW(nullptr, L"Direct3D 11 is not available.", L"bbbridge", MB_ICONERROR);
|
||||
return 1;
|
||||
}
|
||||
ShowWindow(hwnd, SW_SHOWDEFAULT);
|
||||
UpdateWindow(hwnd);
|
||||
|
||||
IMGUI_CHECKVERSION();
|
||||
ImGui::CreateContext();
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
io.IniFilename = nullptr;
|
||||
ImGui::StyleColorsDark();
|
||||
ImGuiStyle& style = ImGui::GetStyle();
|
||||
style.WindowPadding = ImVec2(14, 12);
|
||||
style.FrameRounding = 4;
|
||||
style.ScaleAllSizes(scale);
|
||||
static const ImWchar ranges[] = {0x0020, 0x00FF, 0x0100, 0x017F, 0x2013, 0x2026, 0}; // Latin-1 + Ext-A
|
||||
if (GetFileAttributesW(L"C:\\Windows\\Fonts\\segoeui.ttf") != INVALID_FILE_ATTRIBUTES)
|
||||
io.Fonts->AddFontFromFileTTF("C:\\Windows\\Fonts\\segoeui.ttf", 17.0f * scale, nullptr, ranges);
|
||||
ImGui_ImplWin32_Init(hwnd);
|
||||
ImGui_ImplDX11_Init(g_device, g_context);
|
||||
|
||||
{
|
||||
App app(hwnd);
|
||||
bool done = false;
|
||||
double last_input = now();
|
||||
while (!done) {
|
||||
// Full rate (vsync) only while the player is using the window; idle
|
||||
// ~10 frames a second (the log and status still move), minimized 4 -
|
||||
// the game is the one that should have the CPU and GPU.
|
||||
const bool minimized = IsIconic(hwnd) != 0;
|
||||
if (now() - last_input > 0.5)
|
||||
MsgWaitForMultipleObjects(0, nullptr, FALSE, minimized ? 250 : 100, QS_ALLINPUT);
|
||||
MSG msg;
|
||||
while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) {
|
||||
TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
if (msg.message == WM_QUIT) done = true;
|
||||
if (!minimized && msg.message != WM_TIMER && msg.message != WM_PAINT) last_input = now();
|
||||
}
|
||||
if (done) break;
|
||||
if (app.wants_close()) {
|
||||
DestroyWindow(hwnd);
|
||||
continue;
|
||||
}
|
||||
if (g_resize_w) {
|
||||
release_rtv();
|
||||
g_swap->ResizeBuffers(0, g_resize_w, g_resize_h, DXGI_FORMAT_UNKNOWN, 0);
|
||||
g_resize_w = g_resize_h = 0;
|
||||
create_rtv();
|
||||
}
|
||||
ImGui_ImplDX11_NewFrame();
|
||||
ImGui_ImplWin32_NewFrame();
|
||||
ImGui::NewFrame();
|
||||
app.frame();
|
||||
ImGui::Render();
|
||||
if (minimized) continue; // the app still runs (status, close); nothing to show
|
||||
const float clear[4] = {0.07f, 0.07f, 0.08f, 1};
|
||||
g_context->OMSetRenderTargets(1, &g_rtv, nullptr);
|
||||
g_context->ClearRenderTargetView(g_rtv, clear);
|
||||
ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData());
|
||||
g_swap->Present(1, 0);
|
||||
}
|
||||
} // the App stops the bridge here
|
||||
ImGui_ImplDX11_Shutdown();
|
||||
ImGui_ImplWin32_Shutdown();
|
||||
ImGui::DestroyContext();
|
||||
destroy_device();
|
||||
UnregisterClassW(wc.lpszClassName, wc.hInstance);
|
||||
CoUninitialize();
|
||||
if (single) CloseHandle(single);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#include <winver.h>
|
||||
|
||||
VS_VERSION_INFO VERSIONINFO
|
||||
FILEVERSION 0,3,2,0
|
||||
PRODUCTVERSION 0,3,2,0
|
||||
FILEFLAGSMASK 0x3fL
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
BEGIN
|
||||
BLOCK "StringFileInfo"
|
||||
BEGIN
|
||||
BLOCK "040904b0"
|
||||
BEGIN
|
||||
VALUE "FileDescription", "bbbridge - co-op with bbhost players for bbport"
|
||||
VALUE "FileVersion", "0.3.2"
|
||||
VALUE "ProductName", "bbbridge"
|
||||
VALUE "ProductVersion", "0.3.2"
|
||||
VALUE "LegalCopyright", "GPL-3.0-or-later"
|
||||
VALUE "OriginalFilename", "bbbridge.exe"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
BEGIN
|
||||
VALUE "Translation", 0x409, 1200
|
||||
END
|
||||
END
|
||||
@@ -0,0 +1,141 @@
|
||||
#include "bbhost_client.hpp"
|
||||
|
||||
#include "http.hpp"
|
||||
|
||||
namespace bb {
|
||||
|
||||
BBHostClient::Reply BBHostClient::post(const std::string& path, const json& body, int timeout_ms, http::Pool* pool) {
|
||||
http::ClientRequest req;
|
||||
req.method = "POST";
|
||||
req.url = base_ + path;
|
||||
req.body = body.dump();
|
||||
req.timeout_ms = timeout_ms;
|
||||
req.headers = {{"Content-Type", "application/json"}, {"Accept", "application/json"}, {"User-Agent", user_agent}};
|
||||
const std::string tok = token();
|
||||
if (!tok.empty()) req.headers.emplace_back("X-BB-Token", tok);
|
||||
if (!ruleset.empty()) req.headers.emplace_back("X-BBHost-Ruleset", ruleset);
|
||||
http::ClientResponse res = pool ? pool->send(req) : http::send(req);
|
||||
if (!res.ok()) throw BridgeError(path + ": " + res.error);
|
||||
Reply r;
|
||||
r.status = res.status;
|
||||
r.body = json::parse(res.body, nullptr, false);
|
||||
if (r.body.is_discarded()) r.body = json::object();
|
||||
return r;
|
||||
}
|
||||
|
||||
json BBHostClient::call(const std::string& path, const json& body, int timeout_ms) {
|
||||
Reply r = post(path, body, timeout_ms);
|
||||
if (r.status < 200 || r.status >= 300)
|
||||
throw BridgeError(fmt("%s: HTTP %d %s", path.c_str(), r.status, jstr(r.body, "Message").c_str()));
|
||||
if (!r.body.is_object() || !r.body.contains("ResKind"))
|
||||
throw BridgeError(fmt("%s: no account/matching service (HTTP %d)", path.c_str(), r.status));
|
||||
if (jint(r.body, "ResKind", -1) != 0)
|
||||
throw BridgeError(path + ": refused: " + jstr(r.body, "Message", "unknown"));
|
||||
return r.body;
|
||||
}
|
||||
|
||||
std::string BBHostClient::account_create(const std::string& name) {
|
||||
json r = call("/auth/account/create", {{"Name", name}, {"DeviceName", "bbbridge"}});
|
||||
set_identity(jstr(r, "Token"), jstr(r, "OnlineId", name));
|
||||
return jstr(r, "RecoveryCode");
|
||||
}
|
||||
|
||||
std::string BBHostClient::account_recover(const std::string& name, const std::string& code) {
|
||||
json r = call("/auth/recover", {{"Name", name}, {"RecoveryCode", code}});
|
||||
set_identity(jstr(r, "Token"), jstr(r, "OnlineId", name));
|
||||
return jstr(r, "RecoveryCode");
|
||||
}
|
||||
|
||||
std::optional<bool> BBHostClient::token_valid(const std::string& online_id) {
|
||||
try {
|
||||
Reply r = post("/np/signaling/resolve", {{"OnlineId", online_id}}, 6000);
|
||||
if (r.status == 401) return false;
|
||||
if (r.status >= 200 && r.status < 500) return true;
|
||||
} catch (const BridgeError&) {
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
json BBHostClient::context_start(const std::string& online_id, const Endpoint& signaling, const Endpoint& mapped) {
|
||||
return call("/mp/matching2/context_start", {{"OnlineId", online_id},
|
||||
{"SignalingAddr", signaling.ip},
|
||||
{"SignalingPort", signaling.port},
|
||||
{"MappedAddr", mapped.ip},
|
||||
{"MappedPort", mapped.port}});
|
||||
}
|
||||
|
||||
json BBHostClient::create_room(const std::string& online_id, const Endpoint& local, const Endpoint& mapped,
|
||||
int max_members, const json& extra) {
|
||||
json body = {{"OnlineId", online_id}, {"LocalAddr", local.ip}, {"LocalPort", local.port},
|
||||
{"PublicAddr", local.ip}, {"PublicPort", local.port}, {"MappedAddr", mapped.ip},
|
||||
{"MappedPort", mapped.port}, {"MaxMembers", max_members}};
|
||||
if (extra.is_object())
|
||||
for (auto it = extra.begin(); it != extra.end(); ++it) body[it.key()] = it.value();
|
||||
return call("/mp/matching2/create_room", body);
|
||||
}
|
||||
|
||||
json BBHostClient::join_room(const std::string& online_id, uint64_t room_id, const Endpoint& local,
|
||||
const Endpoint& mapped) {
|
||||
return call("/mp/matching2/join_room", {{"OnlineId", online_id}, {"RoomId", room_id},
|
||||
{"LocalAddr", local.ip}, {"LocalPort", local.port},
|
||||
{"PublicAddr", local.ip}, {"PublicPort", local.port},
|
||||
{"MappedAddr", mapped.ip}, {"MappedPort", mapped.port}});
|
||||
}
|
||||
|
||||
void BBHostClient::leave_room(const std::string& session_id, int member_id) {
|
||||
call("/mp/matching2/leave_room", {{"SessionId", session_id}, {"MemberId", member_id}});
|
||||
}
|
||||
|
||||
int BBHostClient::heartbeat(const std::string& session_id, int member_id) {
|
||||
try {
|
||||
json r = call("/mp/matching2/heartbeat", {{"SessionId", session_id}, {"MemberId", member_id}}, 4000);
|
||||
return static_cast<int>(jint(r, "InRoom", 1));
|
||||
} catch (const BridgeError&) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
void BBHostClient::kick_member(const std::string& session_id, int member_id, int kicker_id) {
|
||||
call("/mp/matching2/kick_member",
|
||||
{{"SessionId", session_id}, {"MemberId", member_id}, {"KickerMemberId", kicker_id}, {"OptData", ""}});
|
||||
}
|
||||
|
||||
void BBHostClient::signaling_update(const std::string& online_id, const Endpoint& mapped,
|
||||
const std::string& session_id, int member_id) {
|
||||
try {
|
||||
call("/mp/matching2/signaling_update", {{"OnlineId", online_id}, {"MappedAddr", mapped.ip},
|
||||
{"MappedPort", mapped.port}, {"SessionId", session_id},
|
||||
{"MemberId", member_id}});
|
||||
} catch (const BridgeError& e) {
|
||||
log(std::string("signaling_update failed: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<Endpoint> BBHostClient::resolve_peer(const std::string& online_id) {
|
||||
try {
|
||||
json r = call("/np/signaling/resolve", {{"OnlineId", online_id}});
|
||||
Endpoint e{jstr(r, "Addr"), static_cast<int>(jint(r, "Port"))};
|
||||
if (e.valid()) return e;
|
||||
} catch (const BridgeError&) {
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
uint64_t BBHostClient::poll_events(const std::string& online_id, uint64_t cursor, json& event) {
|
||||
Reply r = post("/np/events/poll", {{"OnlineId", online_id}, {"Cursor", cursor}, {"Categories", {"matching2"}}},
|
||||
5000, &poll_pool_);
|
||||
if (r.status == 401) throw BridgeError("server refused our token (HTTP 401)");
|
||||
if (r.status < 200 || r.status >= 300) throw BridgeError(fmt("/np/events/poll: HTTP %d", r.status));
|
||||
event = jint(r.body, "HasEvent", 0) ? r.body : json();
|
||||
return static_cast<uint64_t>(jint(r.body, "NextCursor", static_cast<int64_t>(cursor)));
|
||||
}
|
||||
|
||||
void BBHostClient::ack_event(const std::string& online_id, uint64_t cursor) {
|
||||
try {
|
||||
post("/np/events/ack", {{"OnlineId", online_id}, {"Cursor", cursor}}, 4000);
|
||||
} catch (const BridgeError& e) {
|
||||
log(std::string("event ack failed: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace bb
|
||||
@@ -0,0 +1,75 @@
|
||||
// The Hunter's Dream (bbhost server) REST client. The contract is bbhost's own
|
||||
// client code (GPL-3.0): src/net/http.cpp (headers, ResKind), src/net/account.cpp,
|
||||
// src/net/session.cpp, src/hle/np_matching2.cpp.
|
||||
#pragma once
|
||||
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
#include "http.hpp"
|
||||
#include "util.hpp"
|
||||
|
||||
namespace bb {
|
||||
|
||||
struct BridgeError : std::runtime_error {
|
||||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
class BBHostClient {
|
||||
public:
|
||||
explicit BBHostClient(std::string base) : base_(std::move(base)) {
|
||||
while (!base_.empty() && base_.back() == '/') base_.pop_back();
|
||||
}
|
||||
|
||||
std::string user_agent = "bbbridge";
|
||||
std::string ruleset = "vanilla";
|
||||
|
||||
std::string token() const { std::lock_guard<std::mutex> lk(mu_); return token_; }
|
||||
std::string online_id() const { std::lock_guard<std::mutex> lk(mu_); return online_id_; }
|
||||
void set_identity(const std::string& token, const std::string& online_id) {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
token_ = token;
|
||||
online_id_ = online_id;
|
||||
}
|
||||
|
||||
// account; each returns the recovery code the server sent ("" if none)
|
||||
std::string account_create(const std::string& name);
|
||||
std::string account_recover(const std::string& name, const std::string& code);
|
||||
// true accepted, false refused (401), nullopt when it could not be asked
|
||||
std::optional<bool> token_valid(const std::string& online_id);
|
||||
|
||||
// matching
|
||||
json context_start(const std::string& online_id, const Endpoint& signaling, const Endpoint& mapped);
|
||||
json create_room(const std::string& online_id, const Endpoint& local, const Endpoint& mapped, int max_members,
|
||||
const json& extra);
|
||||
json join_room(const std::string& online_id, uint64_t room_id, const Endpoint& local, const Endpoint& mapped);
|
||||
void leave_room(const std::string& session_id, int member_id);
|
||||
int heartbeat(const std::string& session_id, int member_id); // InRoom, -1 when not asked
|
||||
void kick_member(const std::string& session_id, int member_id, int kicker_id);
|
||||
void signaling_update(const std::string& online_id, const Endpoint& mapped, const std::string& session_id,
|
||||
int member_id);
|
||||
std::optional<Endpoint> resolve_peer(const std::string& online_id);
|
||||
|
||||
// events: returns the next cursor; `event` is null when there was none
|
||||
uint64_t poll_events(const std::string& online_id, uint64_t cursor, json& event);
|
||||
void ack_event(const std::string& online_id, uint64_t cursor);
|
||||
|
||||
private:
|
||||
struct Reply {
|
||||
int status = 0;
|
||||
json body;
|
||||
};
|
||||
// throws on transport failure; pool: a connection of its own (else the shared one)
|
||||
Reply post(const std::string& path, const json& body, int timeout_ms = 8000, http::Pool* pool = nullptr);
|
||||
json call(const std::string& path, const json& body, int timeout_ms = 8000); // + ResKind == 0
|
||||
|
||||
http::Pool poll_pool_{45.0}; // the event poll, every 0.4 s: redialled before it is a minute old
|
||||
std::string base_;
|
||||
mutable std::mutex mu_;
|
||||
std::string token_;
|
||||
std::string online_id_;
|
||||
};
|
||||
|
||||
} // namespace bb
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,195 @@
|
||||
// bbbridge core: a local shadNet server for bbport that speaks The Hunter's
|
||||
// Dream's REST API (bbhost's protocol) behind it, plus the P2P gateway, the
|
||||
// WebAPI the game's summon calls go to, and the game-server proxy.
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "bbhost_client.hpp"
|
||||
#include "http.hpp"
|
||||
#include "p2p.hpp"
|
||||
#include "util.hpp"
|
||||
|
||||
namespace bb {
|
||||
|
||||
uint64_t account_id_for(const std::string& npid); // the PSN-style id bbport hands the game
|
||||
|
||||
struct Config {
|
||||
std::string bbhost_server = "https://thehuntersdream.com:18671";
|
||||
std::string auth_server = "https://thehuntersdream.com"; // Discord sign-in, website codes (bbhost's auth_server)
|
||||
std::string community_server = "https://thehuntersdream.com:18671";
|
||||
std::string bind_host = "127.0.0.1";
|
||||
int tcp_port = 31313, udp_port = 31314, webapi_port = 31315;
|
||||
bool p2p_gateway = true;
|
||||
int p2p_port = 0; // outer UDP port, 0 = any
|
||||
int port_p2p_port = 3658; // bbport's P2P UDP port (SHADPS4_P2P_PORT)
|
||||
std::string stun_server; // "host:port", "" = the bbhost host on 3478, "off"
|
||||
std::string local_addr; // LAN address to register, "" = detected
|
||||
double poll_interval = 0.4;
|
||||
std::string state_file = "bbbridge_state.json";
|
||||
bool trace = false;
|
||||
bool debug_endpoints = false; // /debug/* on the WebAPI (tests only)
|
||||
bool create_accounts = false; // make an account for an unknown NPID (tests); players sign in with Discord
|
||||
double stun_refresh = 15.0; // seconds between STUN refreshes (tests shorten it)
|
||||
std::map<int, std::string> game_proxy_routes = {{31316, "https://thehuntersdream.com:20443"},
|
||||
{31317, "https://thehuntersdream.com:18671"}};
|
||||
std::string user_agent = std::string("bbbridge/") + kVersion;
|
||||
|
||||
static Config from_json(const json& j);
|
||||
std::optional<std::pair<std::string, int>> stun_endpoint() const;
|
||||
};
|
||||
|
||||
class Bridge;
|
||||
|
||||
class TcpConn {
|
||||
public:
|
||||
explicit TcpConn(SOCKET s) : sock(s) {}
|
||||
bool send(const std::string& data);
|
||||
void close();
|
||||
SOCKET sock;
|
||||
std::atomic<bool> alive{true};
|
||||
|
||||
private:
|
||||
std::mutex send_mu_;
|
||||
};
|
||||
|
||||
struct RoomState {
|
||||
uint64_t room_id = 0;
|
||||
std::string session_id;
|
||||
int member_id = 0, owner_id = 0, max_slot = 5;
|
||||
std::map<int, json> members; // member_id -> {OnlineId, Addr, Port, LocalAddr, LocalPort, MappedAddr, MappedPort}
|
||||
void clear() { *this = RoomState{}; }
|
||||
};
|
||||
|
||||
class Session {
|
||||
public:
|
||||
Session(Bridge& bridge, std::shared_ptr<TcpConn> conn);
|
||||
~Session();
|
||||
std::pair<uint8_t, std::string> handle(uint16_t command, uint64_t packet_id, const std::string& payload);
|
||||
void shutdown();
|
||||
|
||||
bool logged_in() const { return logged_in_; }
|
||||
std::string npid() const;
|
||||
BBHostClient bb;
|
||||
std::atomic<bool> terminate_requested{false};
|
||||
std::shared_ptr<TcpConn> conn;
|
||||
|
||||
// for the bridge (mapped-address change, summon advertisement, app status)
|
||||
void on_mapped_change(const Endpoint& mapped);
|
||||
void set_advertisement(const json& adv);
|
||||
uint64_t room_id() const;
|
||||
int room_members() const;
|
||||
|
||||
private:
|
||||
using Reply = std::pair<uint8_t, std::string>;
|
||||
Reply on_login(const std::string& payload);
|
||||
Reply on_get_token();
|
||||
Reply on_context_start(const std::string& payload);
|
||||
Reply on_context_stop();
|
||||
Reply on_get_world_info_list();
|
||||
Reply on_create_room(const std::string& payload);
|
||||
Reply on_join_room(const std::string& payload);
|
||||
Reply on_leave_room(const std::string& payload);
|
||||
Reply on_request_signaling_infos(const std::string& payload);
|
||||
Reply on_room_id_echo(const std::string& payload); // SetRoomData{Internal,External}
|
||||
Reply on_kickout(const std::string& payload);
|
||||
|
||||
bool bbhost_account();
|
||||
std::pair<Endpoint, Endpoint> p2p_addresses();
|
||||
json host_extra_from_advertisement() const;
|
||||
static Endpoint member_endpoint(const json& member);
|
||||
Endpoint port_endpoint_for(const json& member);
|
||||
std::optional<Endpoint> port_p2p_endpoint() const;
|
||||
void forget_peers(std::optional<int> member_id = std::nullopt);
|
||||
void ensure_self_member();
|
||||
void absorb_members(const json& members);
|
||||
std::string build_details(int me_id, int owner_id);
|
||||
std::string member_data(const json& member, int owner_id);
|
||||
void leave_room_quietly();
|
||||
void send_room_event(uint32_t event, uint32_t cause, const json& member, int64_t error_code = 0);
|
||||
void start_pump();
|
||||
void pump_main();
|
||||
void handle_event(const json& event);
|
||||
|
||||
Bridge& bridge_;
|
||||
mutable std::recursive_mutex mu_; // the session's state; the TCP and pump threads share it
|
||||
std::string npid_, title_id_;
|
||||
uint64_t ctx_id_ = 0;
|
||||
std::atomic<bool> logged_in_{false};
|
||||
uint64_t account_id_ = 0;
|
||||
RoomState room_;
|
||||
json advertisement_;
|
||||
std::vector<json> pending_guest_invites_;
|
||||
std::thread pump_;
|
||||
std::atomic<bool> pump_running_{false};
|
||||
std::mutex join_mu_; // shutdown() can come from the TCP thread and Bridge::stop at once
|
||||
std::mutex pump_mu_;
|
||||
std::condition_variable pump_cv_;
|
||||
bool pump_stop_ = false;
|
||||
};
|
||||
|
||||
struct BridgeStatus {
|
||||
bool running = false;
|
||||
std::string npid;
|
||||
bool logged_in = false;
|
||||
uint64_t room_id = 0;
|
||||
int room_members = 0;
|
||||
int relay_port = 0;
|
||||
std::optional<Endpoint> mapped;
|
||||
std::vector<p2p::PeerInfo> peers;
|
||||
};
|
||||
|
||||
class Bridge {
|
||||
public:
|
||||
explicit Bridge(Config cfg) : cfg(std::move(cfg)) {}
|
||||
~Bridge() { stop(); }
|
||||
bool start(std::string* error);
|
||||
void stop();
|
||||
BridgeStatus status() const;
|
||||
|
||||
const Config cfg;
|
||||
std::unique_ptr<p2p::Gateway> gateway;
|
||||
|
||||
// shared with sessions
|
||||
json load_state();
|
||||
void save_state(const json& state);
|
||||
std::optional<Endpoint> registry_get(const std::string& npid) const;
|
||||
std::pair<std::string, std::string> identity() const; // (token, ruleset) of the logged-in player
|
||||
json last_advertisement() const;
|
||||
|
||||
private:
|
||||
void tcp_accept_loop();
|
||||
void tcp_serve(std::shared_ptr<TcpConn> conn);
|
||||
void stun_loop();
|
||||
http::ServerResponse webapi(const http::ServerRequest& req);
|
||||
http::ServerResponse summon(const http::ServerRequest& req);
|
||||
http::ServerResponse game_proxy(const std::string& upstream, const http::ServerRequest& req);
|
||||
void capture(const std::string& name, const std::string& request, int status, const std::string& response,
|
||||
const std::string& note);
|
||||
void on_mapped_change(const Endpoint& mapped);
|
||||
|
||||
SOCKET tcp_ = INVALID_SOCKET, stun_ = INVALID_SOCKET;
|
||||
std::thread tcp_thread_, stun_thread_;
|
||||
std::atomic<bool> running_{false};
|
||||
std::atomic<int> tcp_active_{0};
|
||||
http::Server webapi_server_;
|
||||
std::vector<std::unique_ptr<http::Server>> proxies_;
|
||||
|
||||
mutable std::mutex sessions_mu_;
|
||||
std::vector<std::shared_ptr<Session>> sessions_;
|
||||
mutable std::mutex registry_mu_;
|
||||
std::map<std::string, Endpoint> registry_; // npid -> the port's endpoint, from its STUN pings
|
||||
json last_advertisement_;
|
||||
std::mutex state_mu_;
|
||||
};
|
||||
|
||||
} // namespace bb
|
||||
@@ -0,0 +1,437 @@
|
||||
#include "http.hpp"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <winhttp.h>
|
||||
#else
|
||||
#include <curl/curl.h>
|
||||
#endif
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <sstream>
|
||||
|
||||
#ifdef _WIN32
|
||||
#pragma comment(lib, "winhttp.lib")
|
||||
#pragma comment(lib, "ws2_32.lib")
|
||||
#endif
|
||||
|
||||
namespace http {
|
||||
|
||||
namespace {
|
||||
|
||||
bool iequals(const std::string& a, const std::string& b) {
|
||||
return a.size() == b.size() &&
|
||||
std::equal(a.begin(), a.end(), b.begin(), [](char x, char y) {
|
||||
return std::tolower(static_cast<unsigned char>(x)) == std::tolower(static_cast<unsigned char>(y));
|
||||
});
|
||||
}
|
||||
|
||||
std::string trim(const std::string& s) {
|
||||
size_t a = 0, b = s.size();
|
||||
while (a < b && (s[a] == ' ' || s[a] == '\t')) ++a;
|
||||
while (b > a && (s[b - 1] == ' ' || s[b - 1] == '\t' || s[b - 1] == '\r')) --b;
|
||||
return s.substr(a, b - a);
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
HINTERNET open_session() {
|
||||
return WinHttpOpen(L"bbbridge", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, WINHTTP_NO_PROXY_NAME,
|
||||
WINHTTP_NO_PROXY_BYPASS, 0);
|
||||
}
|
||||
|
||||
HINTERNET session() {
|
||||
static HINTERNET h = open_session();
|
||||
return h;
|
||||
}
|
||||
|
||||
ClientResponse send_on(HINTERNET session, const ClientRequest& req);
|
||||
|
||||
struct Handle {
|
||||
HINTERNET h = nullptr;
|
||||
~Handle() { if (h) WinHttpCloseHandle(h); }
|
||||
};
|
||||
#endif
|
||||
|
||||
const char* reason(int status) {
|
||||
switch (status) {
|
||||
case 200: return "OK";
|
||||
case 201: return "Created";
|
||||
case 204: return "No Content";
|
||||
case 400: return "Bad Request";
|
||||
case 401: return "Unauthorized";
|
||||
case 404: return "Not Found";
|
||||
case 500: return "Internal Server Error";
|
||||
case 502: return "Bad Gateway";
|
||||
default: return "Status";
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string header_value(const Headers& headers, const std::string& name) {
|
||||
for (const auto& [k, v] : headers)
|
||||
if (iequals(k, name)) return v;
|
||||
return "";
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
ClientResponse send(const ClientRequest& req) { return send_on(session(), req); }
|
||||
|
||||
Pool::~Pool() {
|
||||
if (session_) WinHttpCloseHandle(session_);
|
||||
}
|
||||
|
||||
ClientResponse Pool::send(const ClientRequest& req) {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
if (session_ && bb::now() - opened_ >= max_age_) { // closing the session closes its connection
|
||||
WinHttpCloseHandle(session_);
|
||||
session_ = nullptr;
|
||||
}
|
||||
if (!session_) {
|
||||
session_ = open_session();
|
||||
opened_ = bb::now();
|
||||
}
|
||||
return send_on(session_, req);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
ClientResponse send_on(HINTERNET session, const ClientRequest& req) {
|
||||
ClientResponse res;
|
||||
if (!session) {
|
||||
res.error = "WinHttpOpen failed";
|
||||
return res;
|
||||
}
|
||||
const std::wstring wurl = bb::widen(req.url);
|
||||
URL_COMPONENTS uc{};
|
||||
uc.dwStructSize = sizeof(uc);
|
||||
wchar_t host[256] = {}, path[4096] = {};
|
||||
uc.lpszHostName = host;
|
||||
uc.dwHostNameLength = 256;
|
||||
uc.lpszUrlPath = path;
|
||||
uc.dwUrlPathLength = 4096;
|
||||
wchar_t extra[4096] = {};
|
||||
uc.lpszExtraInfo = extra;
|
||||
uc.dwExtraInfoLength = 4096;
|
||||
if (!WinHttpCrackUrl(wurl.c_str(), 0, 0, &uc)) {
|
||||
res.error = "bad url " + req.url;
|
||||
return res;
|
||||
}
|
||||
const bool secure = uc.nScheme == INTERNET_SCHEME_HTTPS;
|
||||
Handle conn{WinHttpConnect(session, host, uc.nPort, 0)};
|
||||
if (!conn.h) {
|
||||
res.error = bb::fmt("connect failed (%lu)", GetLastError());
|
||||
return res;
|
||||
}
|
||||
const std::wstring object = std::wstring(path) + extra;
|
||||
Handle request{WinHttpOpenRequest(conn.h, bb::widen(req.method).c_str(), object.c_str(), nullptr,
|
||||
WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES,
|
||||
secure ? WINHTTP_FLAG_SECURE : 0)};
|
||||
if (!request.h) {
|
||||
res.error = bb::fmt("open request failed (%lu)", GetLastError());
|
||||
return res;
|
||||
}
|
||||
DWORD policy = WINHTTP_OPTION_REDIRECT_POLICY_NEVER;
|
||||
WinHttpSetOption(request.h, WINHTTP_OPTION_REDIRECT_POLICY, &policy, sizeof(policy));
|
||||
WinHttpSetTimeouts(request.h, req.timeout_ms, req.timeout_ms, req.timeout_ms, req.timeout_ms);
|
||||
std::wstring headers;
|
||||
for (const auto& [k, v] : req.headers) headers += bb::widen(k + ": " + v + "\r\n");
|
||||
if (!headers.empty())
|
||||
WinHttpAddRequestHeaders(request.h, headers.c_str(), static_cast<DWORD>(-1L),
|
||||
WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE);
|
||||
const DWORD len = static_cast<DWORD>(req.body.size());
|
||||
if (!WinHttpSendRequest(request.h, WINHTTP_NO_ADDITIONAL_HEADERS, 0,
|
||||
len ? const_cast<char*>(req.body.data()) : WINHTTP_NO_REQUEST_DATA, len, len, 0) ||
|
||||
!WinHttpReceiveResponse(request.h, nullptr)) {
|
||||
const DWORD e = GetLastError();
|
||||
res.error = e == ERROR_WINHTTP_TIMEOUT ? "The read operation timed out" : bb::fmt("request failed (%lu)", e);
|
||||
return res;
|
||||
}
|
||||
DWORD status = 0, size = sizeof(status);
|
||||
WinHttpQueryHeaders(request.h, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
|
||||
WINHTTP_HEADER_NAME_BY_INDEX, &status, &size, WINHTTP_NO_HEADER_INDEX);
|
||||
res.status = static_cast<int>(status);
|
||||
size = 0;
|
||||
WinHttpQueryHeaders(request.h, WINHTTP_QUERY_RAW_HEADERS_CRLF, WINHTTP_HEADER_NAME_BY_INDEX, nullptr,
|
||||
&size, WINHTTP_NO_HEADER_INDEX);
|
||||
if (size) {
|
||||
std::wstring raw(size / sizeof(wchar_t), L'\0');
|
||||
if (WinHttpQueryHeaders(request.h, WINHTTP_QUERY_RAW_HEADERS_CRLF, WINHTTP_HEADER_NAME_BY_INDEX,
|
||||
raw.data(), &size, WINHTTP_NO_HEADER_INDEX)) {
|
||||
std::istringstream lines(bb::narrow(raw));
|
||||
std::string line;
|
||||
bool first = true;
|
||||
while (std::getline(lines, line)) {
|
||||
if (first) { first = false; continue; } // status line
|
||||
const size_t c = line.find(':');
|
||||
if (c != std::string::npos) res.headers.emplace_back(trim(line.substr(0, c)), trim(line.substr(c + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
while (true) {
|
||||
DWORD avail = 0;
|
||||
if (!WinHttpQueryDataAvailable(request.h, &avail)) {
|
||||
res.error = bb::fmt("read failed (%lu)", GetLastError());
|
||||
return res;
|
||||
}
|
||||
if (!avail) break;
|
||||
std::string chunk(avail, '\0');
|
||||
DWORD read = 0;
|
||||
if (!WinHttpReadData(request.h, chunk.data(), avail, &read)) {
|
||||
res.error = bb::fmt("read failed (%lu)", GetLastError());
|
||||
return res;
|
||||
}
|
||||
res.body.append(chunk.data(), read);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
#else
|
||||
// Linux: libcurl, as WinHTTP above: certificates checked, no redirects followed, the request's
|
||||
// timeout for connecting and again for the answer, and a Pool keeping one connection alive.
|
||||
namespace {
|
||||
|
||||
// The system's CA bundle: libcurl's built-in path is the one of the distribution it was built
|
||||
// on, and the AppImage carries it to others.
|
||||
const char* ca_bundle() {
|
||||
static const char* found = [] {
|
||||
for (const char* p : {"/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt",
|
||||
"/etc/ssl/ca-bundle.pem", "/etc/pki/tls/cacert.pem",
|
||||
"/etc/ssl/cert.pem"})
|
||||
if (access(p, R_OK) == 0) return p;
|
||||
return static_cast<const char*>(nullptr);
|
||||
}();
|
||||
return found;
|
||||
}
|
||||
|
||||
size_t on_body(char* data, size_t size, size_t count, void* user) {
|
||||
static_cast<std::string*>(user)->append(data, size * count);
|
||||
return size * count;
|
||||
}
|
||||
|
||||
size_t on_header(char* data, size_t size, size_t count, void* user) {
|
||||
auto* headers = static_cast<Headers*>(user);
|
||||
std::string line(data, size * count);
|
||||
while (!line.empty() && (line.back() == '\n' || line.back() == '\r')) line.pop_back(); // trim() keeps '\n'
|
||||
line = trim(line);
|
||||
if (line.rfind("HTTP/", 0) == 0) headers->clear(); // a new response (after a 100 Continue)
|
||||
else if (const size_t c = line.find(':'); c != std::string::npos)
|
||||
headers->emplace_back(trim(line.substr(0, c)), trim(line.substr(c + 1)));
|
||||
return size * count;
|
||||
}
|
||||
|
||||
ClientResponse send_on(CURL* curl, const ClientRequest& req) {
|
||||
ClientResponse res;
|
||||
if (!curl) {
|
||||
res.error = "curl_easy_init failed";
|
||||
return res;
|
||||
}
|
||||
curl_easy_reset(curl);
|
||||
curl_easy_setopt(curl, CURLOPT_URL, req.url.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "bbbridge");
|
||||
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L);
|
||||
if (const char* ca = ca_bundle()) curl_easy_setopt(curl, CURLOPT_CAINFO, ca);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, static_cast<long>(req.timeout_ms));
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, static_cast<long>(req.timeout_ms) * 2);
|
||||
if (req.method == "GET") {
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
|
||||
} else if (req.method == "HEAD") {
|
||||
curl_easy_setopt(curl, CURLOPT_NOBODY, 1L);
|
||||
} else {
|
||||
curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, req.method.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, req.body.data());
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, static_cast<long>(req.body.size()));
|
||||
}
|
||||
curl_slist* headers = curl_slist_append(nullptr, "Expect:"); // no 100-continue round trip
|
||||
for (const auto& [k, v] : req.headers) headers = curl_slist_append(headers, (k + ": " + v).c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, on_body);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &res.body);
|
||||
curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, on_header);
|
||||
curl_easy_setopt(curl, CURLOPT_HEADERDATA, &res.headers);
|
||||
const CURLcode code = curl_easy_perform(curl);
|
||||
curl_slist_free_all(headers);
|
||||
if (code != CURLE_OK) {
|
||||
res.error = code == CURLE_OPERATION_TIMEDOUT ? "The read operation timed out"
|
||||
: bb::fmt("request failed (%s)", curl_easy_strerror(code));
|
||||
res.body.clear();
|
||||
res.headers.clear();
|
||||
return res;
|
||||
}
|
||||
long status = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
|
||||
res.status = static_cast<int>(status);
|
||||
return res;
|
||||
}
|
||||
|
||||
CURL* new_handle() {
|
||||
static const bool initialised = curl_global_init(CURL_GLOBAL_DEFAULT) == CURLE_OK;
|
||||
return initialised ? curl_easy_init() : nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ClientResponse send(const ClientRequest& req) {
|
||||
// One connection per request, as WinHTTP's shared session gives no keep-alive guarantee.
|
||||
CURL* curl = new_handle();
|
||||
ClientResponse res = send_on(curl, req);
|
||||
if (curl) curl_easy_cleanup(curl);
|
||||
return res;
|
||||
}
|
||||
|
||||
Pool::~Pool() {
|
||||
if (session_) curl_easy_cleanup(static_cast<CURL*>(session_));
|
||||
}
|
||||
|
||||
ClientResponse Pool::send(const ClientRequest& req) {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
if (session_ && bb::now() - opened_ >= max_age_) { // a new handle dials a new connection
|
||||
curl_easy_cleanup(static_cast<CURL*>(session_));
|
||||
session_ = nullptr;
|
||||
}
|
||||
if (!session_) {
|
||||
session_ = new_handle();
|
||||
opened_ = bb::now();
|
||||
}
|
||||
return send_on(static_cast<CURL*>(session_), req);
|
||||
}
|
||||
#endif
|
||||
|
||||
ServerResponse ServerResponse::json(const bb::json& j, int status) {
|
||||
ServerResponse r;
|
||||
r.status = status;
|
||||
r.body = j.dump();
|
||||
r.headers.emplace_back("Content-Type", "application/json");
|
||||
return r;
|
||||
}
|
||||
|
||||
// ---- server ----
|
||||
|
||||
bool Server::start(const std::string& host, int port, Handler handler, std::string* error) {
|
||||
bb::net_init();
|
||||
handler_ = std::move(handler);
|
||||
listen_ = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
|
||||
#ifdef _WIN32
|
||||
BOOL exclusive = TRUE;
|
||||
setsockopt(listen_, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, reinterpret_cast<const char*>(&exclusive), sizeof(exclusive));
|
||||
#else
|
||||
// Linux keeps a port that just closed (TIME_WAIT) from being bound again without SO_REUSEADDR,
|
||||
// which Windows allows; it still never lets a second program listen on it.
|
||||
int reuse = 1;
|
||||
setsockopt(listen_, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
|
||||
#endif
|
||||
sockaddr_in addr = bb::to_sockaddr(bb::Endpoint{host, port});
|
||||
if (bind(listen_, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0 || ::listen(listen_, 16) != 0) {
|
||||
if (error) *error = bb::fmt("cannot listen on %s:%d (error %d)", host.c_str(), port, WSAGetLastError());
|
||||
closesocket(listen_);
|
||||
listen_ = INVALID_SOCKET;
|
||||
return false;
|
||||
}
|
||||
socklen_t len = sizeof(addr);
|
||||
getsockname(listen_, reinterpret_cast<sockaddr*>(&addr), &len);
|
||||
port_ = ntohs(addr.sin_port);
|
||||
running_ = true;
|
||||
acceptor_ = std::thread([this] { accept_loop(); });
|
||||
return true;
|
||||
}
|
||||
|
||||
void Server::stop() {
|
||||
if (!running_.exchange(false)) return;
|
||||
closesocket(listen_);
|
||||
listen_ = INVALID_SOCKET;
|
||||
if (acceptor_.joinable()) acceptor_.join();
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
for (SOCKET s : clients_) shutdown(s, SD_BOTH);
|
||||
}
|
||||
// Connection threads are detached (the game opens one per request); wait
|
||||
// for the ones still running, which a shutdown socket ends quickly.
|
||||
for (int i = 0; i < 300 && active_.load() > 0; ++i) Sleep(10);
|
||||
}
|
||||
|
||||
void Server::accept_loop() {
|
||||
while (running_) {
|
||||
SOCKET s = accept(listen_, nullptr, nullptr);
|
||||
if (s == INVALID_SOCKET) break;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
clients_.insert(s);
|
||||
}
|
||||
++active_;
|
||||
std::thread([this, s] {
|
||||
serve(s);
|
||||
--active_;
|
||||
}).detach();
|
||||
}
|
||||
}
|
||||
|
||||
void Server::serve(SOCKET s) {
|
||||
std::string buf;
|
||||
char chunk[8192];
|
||||
bool keep = true;
|
||||
while (keep && running_) {
|
||||
size_t end;
|
||||
while ((end = buf.find("\r\n\r\n")) == std::string::npos) {
|
||||
const int n = recv(s, chunk, sizeof(chunk), 0);
|
||||
if (n <= 0) { keep = false; break; }
|
||||
buf.append(chunk, n);
|
||||
if (buf.size() > (1 << 20)) { keep = false; break; }
|
||||
}
|
||||
if (!keep) break;
|
||||
ServerRequest req;
|
||||
std::istringstream head(buf.substr(0, end));
|
||||
std::string line, version;
|
||||
std::getline(head, line);
|
||||
{
|
||||
std::istringstream rl(line);
|
||||
rl >> req.method >> req.target >> version;
|
||||
}
|
||||
while (std::getline(head, line)) {
|
||||
const size_t c = line.find(':');
|
||||
if (c != std::string::npos) req.headers.emplace_back(trim(line.substr(0, c)), trim(line.substr(c + 1)));
|
||||
}
|
||||
buf.erase(0, end + 4);
|
||||
size_t want = 0;
|
||||
const std::string cl = header_value(req.headers, "Content-Length");
|
||||
if (!cl.empty()) {
|
||||
try { want = static_cast<size_t>(std::stoull(cl)); } catch (...) { want = 0; }
|
||||
}
|
||||
while (buf.size() < want) {
|
||||
const int n = recv(s, chunk, sizeof(chunk), 0);
|
||||
if (n <= 0) { keep = false; break; }
|
||||
buf.append(chunk, n);
|
||||
}
|
||||
if (!keep) break;
|
||||
req.body = buf.substr(0, want);
|
||||
buf.erase(0, want);
|
||||
if (iequals(header_value(req.headers, "Connection"), "close") || version == "HTTP/1.0") keep = false;
|
||||
|
||||
ServerResponse res;
|
||||
try {
|
||||
res = handler_(req);
|
||||
} catch (const std::exception& e) {
|
||||
res = ServerResponse::json({{"ResKind", 1}, {"Message", std::string("bridge error: ") + e.what()}}, 500);
|
||||
}
|
||||
std::string out = bb::fmt("HTTP/1.1 %d %s\r\n", res.status, reason(res.status));
|
||||
for (const auto& [k, v] : res.headers)
|
||||
if (!iequals(k, "Content-Length") && !iequals(k, "Connection")) out += k + ": " + v + "\r\n";
|
||||
out += "Content-Length: " + std::to_string(res.body.size()) + "\r\n";
|
||||
out += keep ? "Connection: keep-alive\r\n\r\n" : "Connection: close\r\n\r\n";
|
||||
if (req.method != "HEAD") out += res.body;
|
||||
size_t sent = 0;
|
||||
while (sent < out.size()) {
|
||||
const int n = ::send(s, out.data() + sent, static_cast<int>(out.size() - sent), 0);
|
||||
if (n <= 0) { keep = false; break; }
|
||||
sent += static_cast<size_t>(n);
|
||||
}
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
clients_.erase(s);
|
||||
}
|
||||
closesocket(s);
|
||||
}
|
||||
|
||||
} // namespace http
|
||||
@@ -0,0 +1,101 @@
|
||||
// HTTP: a WinHTTP client (certificates checked by Windows, no redirects
|
||||
// followed) and a small threaded HTTP/1.1 server for the bridge's local
|
||||
// listeners (WebAPI, game-server proxy).
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "util.hpp"
|
||||
|
||||
namespace http {
|
||||
|
||||
using Headers = std::vector<std::pair<std::string, std::string>>;
|
||||
|
||||
std::string header_value(const Headers& headers, const std::string& name); // case-insensitive, "" if absent
|
||||
|
||||
struct ClientRequest {
|
||||
std::string method = "POST";
|
||||
std::string url;
|
||||
Headers headers;
|
||||
std::string body;
|
||||
int timeout_ms = 8000;
|
||||
};
|
||||
|
||||
struct ClientResponse {
|
||||
int status = 0;
|
||||
Headers headers;
|
||||
std::string body;
|
||||
std::string error; // transport failure (no HTTP answer)
|
||||
bool ok() const { return error.empty(); }
|
||||
};
|
||||
|
||||
ClientResponse send(const ClientRequest& req);
|
||||
|
||||
// A WinHTTP session of its own for one caller's requests in a row: its kept-
|
||||
// alive connection is closed and dialled again once it is max_age_s old, before
|
||||
// something on the way drops it silently. (The Hunter's Dream's event poll, on
|
||||
// the shared pool, hung for its whole timeout about once a minute.)
|
||||
class Pool {
|
||||
public:
|
||||
explicit Pool(double max_age_s) : max_age_(max_age_s) {}
|
||||
~Pool();
|
||||
Pool(const Pool&) = delete;
|
||||
Pool& operator=(const Pool&) = delete;
|
||||
ClientResponse send(const ClientRequest& req);
|
||||
|
||||
private:
|
||||
std::mutex mu_;
|
||||
void* session_ = nullptr; // HINTERNET
|
||||
double opened_ = 0, max_age_;
|
||||
};
|
||||
|
||||
struct ServerRequest {
|
||||
std::string method;
|
||||
std::string target; // path with the query
|
||||
Headers headers;
|
||||
std::string body;
|
||||
std::string path() const { return target.substr(0, target.find('?')); }
|
||||
std::string query() const {
|
||||
const size_t q = target.find('?');
|
||||
return q == std::string::npos ? "" : target.substr(q + 1);
|
||||
}
|
||||
};
|
||||
|
||||
struct ServerResponse {
|
||||
int status = 200;
|
||||
Headers headers; // Content-Length is added
|
||||
std::string body;
|
||||
static ServerResponse json(const bb::json& j, int status = 200);
|
||||
};
|
||||
|
||||
using Handler = std::function<ServerResponse(const ServerRequest&)>;
|
||||
|
||||
class Server {
|
||||
public:
|
||||
~Server() { stop(); }
|
||||
bool start(const std::string& host, int port, Handler handler, std::string* error);
|
||||
void stop();
|
||||
int port() const { return port_; }
|
||||
|
||||
private:
|
||||
void accept_loop();
|
||||
void serve(SOCKET s);
|
||||
|
||||
SOCKET listen_ = INVALID_SOCKET;
|
||||
int port_ = 0;
|
||||
Handler handler_;
|
||||
std::thread acceptor_;
|
||||
std::atomic<bool> running_{false};
|
||||
std::mutex mu_;
|
||||
std::set<SOCKET> clients_;
|
||||
std::atomic<int> active_{0}; // detached connection threads still running
|
||||
};
|
||||
|
||||
} // namespace http
|
||||
@@ -0,0 +1,152 @@
|
||||
// bbbridge-cli: the bridge without a window (tests, servers, troubleshooting).
|
||||
// bbbridge-cli[.exe] [--config bbbridge_config.json] [--log bbbridge.log]
|
||||
#include <atomic>
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "account_link.hpp"
|
||||
#include "bridge.hpp"
|
||||
#ifdef _WIN32
|
||||
#include "setup.hpp"
|
||||
#else
|
||||
#include <csignal>
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
std::atomic<bool> g_quit{false};
|
||||
#ifdef _WIN32
|
||||
BOOL WINAPI on_ctrl(DWORD) {
|
||||
g_quit = true;
|
||||
return TRUE;
|
||||
}
|
||||
#else
|
||||
void on_signal(int) { g_quit = true; }
|
||||
#endif
|
||||
|
||||
int run(const std::vector<std::wstring>& argv) {
|
||||
const int argc = static_cast<int>(argv.size());
|
||||
std::wstring config_path = L"bbbridge_config.json", log_path, web_code_npid;
|
||||
bool link = false;
|
||||
std::wstring route, bbport_dir;
|
||||
struct { std::wstring op, name, code; } account_op;
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
const std::wstring a = argv[i];
|
||||
if (a == L"--config" && i + 1 < argc) config_path = argv[++i];
|
||||
else if (a == L"--log" && i + 1 < argc) log_path = argv[++i];
|
||||
else if (a == L"--link-discord") link = true;
|
||||
else if (a == L"--route" && i + 1 < argc) route = argv[++i];
|
||||
else if (a == L"--bbport" && i + 1 < argc) bbport_dir = argv[++i];
|
||||
else if (a == L"--web-code" && i + 1 < argc) web_code_npid = argv[++i];
|
||||
else if (a == L"--create" && i + 1 < argc) account_op = {L"create", argv[++i], L""};
|
||||
else if (a == L"--recover" && i + 2 < argc) {
|
||||
account_op = {L"recover", argv[i + 1], argv[i + 2]};
|
||||
i += 2;
|
||||
} else if (a == L"--sign-out" && i + 1 < argc) account_op = {L"sign-out", argv[++i], L""};
|
||||
else if (a == L"--version") {
|
||||
std::printf("bbbridge %s\n", bb::kVersion);
|
||||
return 0;
|
||||
} else {
|
||||
std::printf("usage: bbbridge-cli [--config file.json] [--log file.log] [--version]\n"
|
||||
" [--link-discord] [--web-code NPID] [--create NAME]\n"
|
||||
" [--recover NAME CODE] [--sign-out NAME]\n"
|
||||
" [--route on|off [--bbport DIR]]\n");
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
if (!log_path.empty()) bb::set_log_file(log_path);
|
||||
bb::json j = bb::json::object();
|
||||
std::ifstream in(bb::fspath(config_path));
|
||||
if (in) {
|
||||
j = bb::json::parse(in, nullptr, false);
|
||||
if (j.is_discarded()) {
|
||||
std::printf("config %s is not valid JSON\n", bb::narrow(config_path).c_str());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
const bb::Config cfg = bb::Config::from_json(j);
|
||||
if (link) { // sign in with Discord, store the account, exit
|
||||
bb::DiscordLink dl(cfg.auth_server, bb::widen(cfg.state_file));
|
||||
dl.start();
|
||||
bool shown = false;
|
||||
while (dl.state() == bb::DiscordLink::State::Starting || dl.state() == bb::DiscordLink::State::Waiting) {
|
||||
if (!shown && dl.state() == bb::DiscordLink::State::Waiting) {
|
||||
std::printf("Open %s and enter the code %s\n", dl.uri().c_str(), dl.user_code().c_str());
|
||||
std::fflush(stdout);
|
||||
shown = true;
|
||||
}
|
||||
Sleep(200);
|
||||
}
|
||||
if (dl.state() != bb::DiscordLink::State::Approved) {
|
||||
std::printf("failed: %s\n", dl.message().c_str());
|
||||
return 1;
|
||||
}
|
||||
std::printf("signed in as %s\n", dl.online_id().c_str());
|
||||
return 0;
|
||||
}
|
||||
if (!route.empty()) { // point bbport at the bridge (on) or back (off), as the app's checkbox
|
||||
#ifndef _WIN32
|
||||
std::printf("failed: --route is for bbport for Windows; on Linux the bbport launcher routes the game\n");
|
||||
return 1;
|
||||
#else
|
||||
try {
|
||||
wchar_t self[MAX_PATH];
|
||||
GetModuleFileNameW(nullptr, self, MAX_PATH);
|
||||
bb::BbportSetup setup(bb::fs::path(self).parent_path(), bbport_dir);
|
||||
if (route == L"on") setup.enable();
|
||||
else setup.disable();
|
||||
std::printf("ok: bbport %s, routed=%d\n", bb::narrow(setup.bbport.wstring()).c_str(), setup.routed() ? 1 : 0);
|
||||
return 0;
|
||||
} catch (const std::exception& e) {
|
||||
std::printf("failed: %s\n", e.what());
|
||||
return 1;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
if (!account_op.op.empty()) {
|
||||
const std::wstring state = bb::widen(cfg.state_file);
|
||||
const std::string name = bb::narrow(account_op.name);
|
||||
const std::string err =
|
||||
account_op.op == L"create" ? bb::account_create_now(cfg.auth_server, state, name)
|
||||
: account_op.op == L"recover" ? bb::account_recover_now(cfg.auth_server, state, name, bb::narrow(account_op.code))
|
||||
: bb::account_sign_out(cfg.auth_server, state, name);
|
||||
std::printf("%s\n", err.empty() ? "ok" : ("failed: " + err).c_str());
|
||||
return err.empty() ? 0 : 1;
|
||||
}
|
||||
if (!web_code_npid.empty()) {
|
||||
std::string error;
|
||||
const std::string code = bb::website_code(cfg.auth_server, bb::widen(cfg.state_file), bb::narrow(web_code_npid), error);
|
||||
if (code.empty()) {
|
||||
std::printf("failed: %s\n", error.c_str());
|
||||
return 1;
|
||||
}
|
||||
std::printf("%s\n", code.c_str());
|
||||
return 0;
|
||||
}
|
||||
bb::log(bb::fmt("bbbridge %s (cli)", bb::kVersion));
|
||||
bb::Bridge bridge(cfg);
|
||||
std::string error;
|
||||
if (!bridge.start(&error)) return 1;
|
||||
#ifdef _WIN32
|
||||
SetConsoleCtrlHandler(on_ctrl, TRUE);
|
||||
#else
|
||||
std::signal(SIGINT, on_signal);
|
||||
std::signal(SIGTERM, on_signal);
|
||||
#endif
|
||||
while (!g_quit) Sleep(200);
|
||||
bb::log("stopping");
|
||||
bridge.stop();
|
||||
return 0;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
#ifdef _WIN32
|
||||
int wmain(int argc, wchar_t** argv) { return run(std::vector<std::wstring>(argv, argv + argc)); }
|
||||
#else
|
||||
int main(int argc, char** argv) {
|
||||
std::vector<std::wstring> args;
|
||||
for (int i = 0; i < argc; ++i) args.push_back(bb::widen(argv[i]));
|
||||
return run(args);
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,735 @@
|
||||
#include "p2p.hpp"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <mstcpip.h>
|
||||
|
||||
#ifndef SIO_UDP_CONNRESET
|
||||
#define SIO_UDP_CONNRESET _WSAIOW(IOC_VENDOR, 12)
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <random>
|
||||
|
||||
namespace bb::p2p {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint16_t kPortSignalingVport = 0xFFFF;
|
||||
constexpr uint8_t kPs4Magic = 0xFF, kFlagP2p = 0x80, kFlagByteVports = 0x40, kFlagComid = 0x20, kFlagType = 0x03;
|
||||
constexpr uint8_t kRelayMagic = 0xFB;
|
||||
constexpr uint32_t kStunCookie = 0x2112A442;
|
||||
constexpr uint16_t kStunBindingRequest = 0x0001, kStunBindingResponse = 0x0101, kStunMapped = 0x0001,
|
||||
kStunXorMapped = 0x0020, kStunXorMappedOld = 0x8020, kBbhostHello = 0x8100,
|
||||
kBbhostRelay = 0x8101;
|
||||
const std::string kRelayVersion = "bbr1";
|
||||
const std::string kShad = "SHAD";
|
||||
|
||||
uint16_t be16(const std::string& s, size_t at) {
|
||||
return static_cast<uint16_t>((static_cast<uint8_t>(s[at]) << 8) | static_cast<uint8_t>(s[at + 1]));
|
||||
}
|
||||
uint32_t be32(const std::string& s, size_t at) {
|
||||
return (static_cast<uint32_t>(be16(s, at)) << 16) | be16(s, at + 2);
|
||||
}
|
||||
void put_be16(std::string& s, uint16_t v) {
|
||||
s.push_back(static_cast<char>(v >> 8));
|
||||
s.push_back(static_cast<char>(v & 0xFF));
|
||||
}
|
||||
template <typename T>
|
||||
void put_le(std::string& s, T v) {
|
||||
for (size_t i = 0; i < sizeof(T); ++i) s.push_back(static_cast<char>((static_cast<uint64_t>(v) >> (8 * i)) & 0xFF));
|
||||
}
|
||||
template <typename T>
|
||||
T get_le(const std::string& s, size_t at) {
|
||||
uint64_t v = 0;
|
||||
for (size_t i = 0; i < sizeof(T); ++i) v |= static_cast<uint64_t>(static_cast<uint8_t>(s[at + i])) << (8 * i);
|
||||
return static_cast<T>(v);
|
||||
}
|
||||
std::string id16(const std::string& name) {
|
||||
std::string out = name.substr(0, 16);
|
||||
out.resize(16, '\0');
|
||||
return out;
|
||||
}
|
||||
std::string ip_from_be32(uint32_t v) {
|
||||
return fmt("%u.%u.%u.%u", (v >> 24) & 0xFF, (v >> 16) & 0xFF, (v >> 8) & 0xFF, v & 0xFF);
|
||||
}
|
||||
uint64_t wall_micros() {
|
||||
using namespace std::chrono;
|
||||
return static_cast<uint64_t>(duration_cast<microseconds>(system_clock::now().time_since_epoch()).count());
|
||||
}
|
||||
void no_udp_connreset(SOCKET s) {
|
||||
#ifdef _WIN32
|
||||
// Without this, an ICMP "port unreachable" for an earlier send makes the
|
||||
// next recvfrom fail with WSAECONNRESET (10054).
|
||||
BOOL off = FALSE;
|
||||
DWORD ret = 0;
|
||||
WSAIoctl(s, SIO_UDP_CONNRESET, &off, sizeof(off), nullptr, 0, &ret, nullptr, nullptr);
|
||||
#else
|
||||
(void)s; // Linux reports ICMP errors only on connected UDP sockets; these are not
|
||||
#endif
|
||||
}
|
||||
SOCKET udp_bind(const std::string& ip, int port, Endpoint* bound) {
|
||||
SOCKET s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
|
||||
if (s == INVALID_SOCKET) return s;
|
||||
sockaddr_in a = to_sockaddr(Endpoint{ip, port});
|
||||
if (bind(s, reinterpret_cast<sockaddr*>(&a), sizeof(a)) != 0) {
|
||||
closesocket(s);
|
||||
return INVALID_SOCKET;
|
||||
}
|
||||
no_udp_connreset(s);
|
||||
socklen_t len = sizeof(a);
|
||||
getsockname(s, reinterpret_cast<sockaddr*>(&a), &len);
|
||||
if (bound) *bound = from_sockaddr(a);
|
||||
return s;
|
||||
}
|
||||
void udp_send(SOCKET s, const std::string& data, const Endpoint& to) {
|
||||
sockaddr_in a = to_sockaddr(to);
|
||||
sendto(s, data.data(), static_cast<int>(data.size()), 0, reinterpret_cast<sockaddr*>(&a), sizeof(a));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const std::string kProbe("\xfe" "bbhp\0\0\0", 8);
|
||||
|
||||
// ---- framing ----
|
||||
|
||||
Frame port_to_ps4(const std::string& frame, std::string& out) {
|
||||
if (frame.size() < 4) return Frame::None;
|
||||
const uint16_t src = be16(frame, 0), dst = be16(frame, 2);
|
||||
const std::string payload = frame.substr(4);
|
||||
if (dst == kPortSignalingVport) {
|
||||
out = payload;
|
||||
return Frame::Signaling;
|
||||
}
|
||||
out.clear();
|
||||
out.push_back(static_cast<char>(kPs4Magic));
|
||||
if (src < 256 && dst < 256) {
|
||||
out.push_back(static_cast<char>(kFlagP2p | kFlagByteVports | kFlagType));
|
||||
out.push_back(static_cast<char>(src));
|
||||
out.push_back(static_cast<char>(dst));
|
||||
} else {
|
||||
out.push_back(static_cast<char>(kFlagP2p | kFlagType));
|
||||
put_be16(out, src);
|
||||
put_be16(out, dst);
|
||||
}
|
||||
out += payload;
|
||||
return Frame::Game;
|
||||
}
|
||||
|
||||
bool ps4_to_port(const std::string& d, std::string& out) {
|
||||
if (d.size() < 4 || static_cast<uint8_t>(d[0]) != kPs4Magic) return false;
|
||||
const uint8_t flags = static_cast<uint8_t>(d[1]);
|
||||
size_t at = 2 + ((flags & kFlagComid) ? 4 : 0);
|
||||
uint16_t src, dst;
|
||||
if (flags & kFlagByteVports) {
|
||||
if (at + 2 > d.size()) return false;
|
||||
src = static_cast<uint8_t>(d[at]);
|
||||
dst = static_cast<uint8_t>(d[at + 1]);
|
||||
at += 2;
|
||||
} else {
|
||||
if (at + 4 > d.size()) return false;
|
||||
src = be16(d, at);
|
||||
dst = be16(d, at + 2);
|
||||
at += 4;
|
||||
}
|
||||
out.clear();
|
||||
put_be16(out, src);
|
||||
put_be16(out, dst);
|
||||
out += d.substr(at);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string port_signaling_frame(const std::string& payload) {
|
||||
std::string out;
|
||||
put_be16(out, kPortSignalingVport);
|
||||
put_be16(out, kPortSignalingVport);
|
||||
return out + payload;
|
||||
}
|
||||
|
||||
std::string relay_wrap(const std::string& token, int port, const std::string& datagram) {
|
||||
std::string out;
|
||||
out.push_back(static_cast<char>(kRelayMagic));
|
||||
out.push_back('R');
|
||||
out += token;
|
||||
put_be16(out, static_cast<uint16_t>(port));
|
||||
return out + datagram;
|
||||
}
|
||||
|
||||
bool relay_unwrap(const std::string& data, int& port, std::string& datagram) {
|
||||
if (data.size() < 4 || static_cast<uint8_t>(data[0]) != kRelayMagic || data[1] != 'r') return false;
|
||||
port = be16(data, 2);
|
||||
datagram = data.substr(4);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string stun_request(const std::string& token, std::string& txid) {
|
||||
static std::mt19937_64 rng{std::random_device{}()};
|
||||
txid.clear();
|
||||
txid.push_back(static_cast<char>(kStunCookie >> 24));
|
||||
txid.push_back(static_cast<char>((kStunCookie >> 16) & 0xFF));
|
||||
txid.push_back(static_cast<char>((kStunCookie >> 8) & 0xFF));
|
||||
txid.push_back(static_cast<char>(kStunCookie & 0xFF));
|
||||
for (int i = 0; i < 12; ++i) txid.push_back(static_cast<char>(rng() & 0xFF));
|
||||
std::string attr;
|
||||
put_be16(attr, kBbhostHello);
|
||||
put_be16(attr, 12);
|
||||
attr += kRelayVersion;
|
||||
attr += token.size() == 8 ? token : std::string(8, '\0');
|
||||
std::string out;
|
||||
put_be16(out, kStunBindingRequest);
|
||||
put_be16(out, static_cast<uint16_t>(attr.size()));
|
||||
return out + txid + attr;
|
||||
}
|
||||
|
||||
bool stun_parse(const std::string& d, const std::string& txid, StunAnswer& out) {
|
||||
if (d.size() < 20 || be16(d, 0) != kStunBindingResponse || d.compare(4, 16, txid) != 0) return false;
|
||||
const size_t length = be16(d, 2);
|
||||
if (20 + length > d.size()) return false;
|
||||
const uint32_t xor_addr = be32(d, 4);
|
||||
const uint16_t xor_port = be16(d, 4);
|
||||
std::optional<std::pair<uint32_t, uint16_t>> mapped, xored;
|
||||
out = StunAnswer{};
|
||||
size_t at = 20;
|
||||
const size_t end = 20 + length;
|
||||
while (at + 4 <= end) {
|
||||
const uint16_t type = be16(d, at), alen = be16(d, at + 2);
|
||||
if (at + 4 + alen > end) break;
|
||||
const std::string v = d.substr(at + 4, alen);
|
||||
if (type == kBbhostRelay && alen >= 20 && v.compare(0, 4, kRelayVersion) == 0) {
|
||||
out.relay = true;
|
||||
out.relay_token = v.substr(4, 8);
|
||||
out.relay_port = be16(v, 12);
|
||||
} else if (alen >= 8 && static_cast<uint8_t>(v[1]) == 0x01) {
|
||||
const uint16_t port = be16(v, 2);
|
||||
const uint32_t addr = be32(v, 4);
|
||||
if (type == kStunMapped) mapped = std::make_pair(addr, port);
|
||||
else if (type == kStunXorMapped || type == kStunXorMappedOld)
|
||||
xored = std::make_pair(addr ^ xor_addr, static_cast<uint16_t>(port ^ xor_port));
|
||||
}
|
||||
at += 4 + ((alen + 3u) & ~3u);
|
||||
}
|
||||
const auto chosen = mapped ? mapped : xored;
|
||||
if (!chosen || !chosen->first || !chosen->second) return false;
|
||||
out.mapped = Endpoint{ip_from_be32(chosen->first), chosen->second};
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- signaling ----
|
||||
|
||||
PeerSignaling::PeerSignaling(const std::string& peer_online_id, int peer_member, const std::string& port_online_id,
|
||||
int port_member, uint64_t room)
|
||||
: peer_id(id16(peer_online_id)),
|
||||
port_id(id16(port_online_id)),
|
||||
peer_member_id(peer_member > 0 ? peer_member : 0),
|
||||
port_member_id(port_member),
|
||||
room_id(room),
|
||||
conn_id(0x4242u + static_cast<uint32_t>(peer_member > 0 ? peer_member : 0)) {}
|
||||
|
||||
std::string PeerSignaling::handshake(uint8_t kind, uint32_t to_conn, uint64_t nonce) const {
|
||||
std::string p = kShad;
|
||||
p.push_back(static_cast<char>(T_HANDSHAKE));
|
||||
p.push_back(static_cast<char>(kind));
|
||||
p += peer_id + port_id;
|
||||
put_le<uint32_t>(p, conn_id);
|
||||
put_le<uint32_t>(p, to_conn);
|
||||
put_le<uint32_t>(p, 0); // mapped addr: 0 makes the port keep the inner socket's address
|
||||
put_le<uint16_t>(p, 0);
|
||||
put_le<uint16_t>(p, 0);
|
||||
put_le<uint64_t>(p, nonce);
|
||||
return p; // 62 bytes
|
||||
}
|
||||
|
||||
std::string PeerSignaling::control(uint8_t kind) const {
|
||||
std::string p = kShad;
|
||||
p.push_back(static_cast<char>(T_CONTROL));
|
||||
p.push_back(static_cast<char>(kind));
|
||||
p += peer_id + port_id;
|
||||
put_le<uint32_t>(p, conn_id);
|
||||
put_le<uint32_t>(p, 0);
|
||||
put_le<uint16_t>(p, 0);
|
||||
put_le<uint16_t>(p, 0);
|
||||
return p; // 50 bytes
|
||||
}
|
||||
|
||||
std::string PeerSignaling::m2(uint8_t kind, uint64_t nonce) const {
|
||||
std::string p = kShad;
|
||||
p.push_back(static_cast<char>(T_M2));
|
||||
p.push_back(static_cast<char>(kind));
|
||||
put_le<uint64_t>(p, room_id);
|
||||
put_le<uint16_t>(p, static_cast<uint16_t>(peer_member_id));
|
||||
put_le<uint16_t>(p, static_cast<uint16_t>(port_member_id));
|
||||
p += peer_id;
|
||||
put_le<uint32_t>(p, 0);
|
||||
put_le<uint16_t>(p, 0);
|
||||
put_le<uint16_t>(p, 0);
|
||||
put_le<uint64_t>(p, nonce);
|
||||
return p; // 50 bytes
|
||||
}
|
||||
|
||||
std::vector<std::string> PeerSignaling::handle(const std::string& p) {
|
||||
if (p.size() < 5 || p.compare(0, 4, kShad) != 0) return {};
|
||||
const uint8_t type = static_cast<uint8_t>(p[4]);
|
||||
if (type == T_ECHO_PING && p.size() == 17) {
|
||||
heard = true;
|
||||
std::string pong = kShad;
|
||||
pong.push_back(static_cast<char>(T_ECHO_PONG));
|
||||
pong += p.substr(5, 12); // conn u32 + timestamp u64, echoed
|
||||
return {pong};
|
||||
}
|
||||
if (type == T_HANDSHAKE && p.size() == 0x3E) {
|
||||
heard = true;
|
||||
const uint8_t kind = static_cast<uint8_t>(p[5]);
|
||||
const uint32_t from_conn = get_le<uint32_t>(p, 38);
|
||||
const uint64_t nonce = get_le<uint64_t>(p, 54);
|
||||
if (kind == HS_OFFER) return {handshake(HS_ACCEPT, from_conn, 0)};
|
||||
if (kind == HS_ACCEPT) return {handshake(HS_CHECK, from_conn, wall_micros())};
|
||||
if (kind == HS_CHECK) return {handshake(HS_CHECK_ACK, from_conn, nonce)};
|
||||
return {};
|
||||
}
|
||||
if (type == T_CONTROL && p.size() == 0x32) {
|
||||
heard = true;
|
||||
const uint8_t kind = static_cast<uint8_t>(p[5]);
|
||||
if (kind == CTL_ACTIVATION_REQUEST) return {control(CTL_ACTIVATION_ACK)};
|
||||
if (kind == CTL_ESTABLISHED && !sent_established) {
|
||||
sent_established = true;
|
||||
return {control(CTL_ESTABLISHED)};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
if (type == T_M2 && p.size() == 0x32) {
|
||||
heard = true;
|
||||
const uint8_t kind = static_cast<uint8_t>(p[5]);
|
||||
const uint64_t room = get_le<uint64_t>(p, 6);
|
||||
const uint16_t from_member = get_le<uint16_t>(p, 14), to_member = get_le<uint16_t>(p, 16);
|
||||
const uint64_t nonce = get_le<uint64_t>(p, 42);
|
||||
if (room) room_id = room;
|
||||
// the port addresses the member it means: learn the ids from it
|
||||
if (to_member && !peer_member_id) peer_member_id = to_member;
|
||||
if (from_member && !port_member_id) port_member_id = from_member;
|
||||
if (kind == HS_OFFER) return {m2(HS_ACCEPT, 0)};
|
||||
if (kind == HS_ACCEPT) return {m2(HS_CHECK, 0)};
|
||||
if (kind == HS_CHECK) return {m2(HS_CHECK_ACK, nonce)};
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::vector<std::string> PeerSignaling::opening() const {
|
||||
std::vector<std::string> out{control(CTL_ACTIVATION_REQUEST)};
|
||||
if (room_id && port_member_id && peer_member_id) out.push_back(m2(HS_OFFER, 0));
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- gateway ----
|
||||
|
||||
Gateway::Gateway(int outer_port, std::optional<std::pair<std::string, int>> stun_server, std::string lan_addr,
|
||||
std::function<void(const Endpoint&)> on_mapped_change)
|
||||
: outer_port_(outer_port),
|
||||
stun_server_(std::move(stun_server)),
|
||||
lan_addr_(std::move(lan_addr)),
|
||||
on_mapped_change_(std::move(on_mapped_change)) {}
|
||||
|
||||
Gateway::~Gateway() { stop(); }
|
||||
|
||||
bool Gateway::start(std::string* error) {
|
||||
net_init();
|
||||
Endpoint bound;
|
||||
outer_ = udp_bind("0.0.0.0", outer_port_, &bound);
|
||||
if (outer_ == INVALID_SOCKET) {
|
||||
if (error) *error = fmt("p2p: cannot bind udp :%d (error %d)", outer_port_, WSAGetLastError());
|
||||
return false;
|
||||
}
|
||||
outer_port_ = bound.port;
|
||||
stop_ = false;
|
||||
threads_.emplace_back([this] { outer_main(); });
|
||||
threads_.emplace_back([this] { inner_main(); });
|
||||
threads_.emplace_back([this] { tick_main(); });
|
||||
log(fmt("p2p: gateway on udp :%d (bbhost framing)", outer_port_));
|
||||
return true;
|
||||
}
|
||||
|
||||
void Gateway::stop() {
|
||||
if (stop_.exchange(true)) return;
|
||||
if (outer_ != INVALID_SOCKET) closesocket(outer_);
|
||||
outer_ = INVALID_SOCKET;
|
||||
for (auto& t : threads_)
|
||||
if (t.joinable()) t.join();
|
||||
threads_.clear();
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
for (auto& [id, link] : peers_) close_link(*link);
|
||||
peers_.clear();
|
||||
}
|
||||
|
||||
void Gateway::close_link(Link& link) {
|
||||
if (link.inner != INVALID_SOCKET) closesocket(link.inner);
|
||||
link.inner = INVALID_SOCKET;
|
||||
}
|
||||
|
||||
std::pair<Endpoint, Endpoint> Gateway::endpoints() const {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
const Endpoint local{lan_addr_, outer_port_};
|
||||
return {local, mapped_ ? *mapped_ : local};
|
||||
}
|
||||
|
||||
std::optional<Endpoint> Gateway::mapped() const {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
return mapped_;
|
||||
}
|
||||
|
||||
int Gateway::relay_port() const {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
return relay_ ? relay_->port : 0;
|
||||
}
|
||||
|
||||
Endpoint Gateway::add_peer(int member_id, const std::string& online_id, const Endpoint& endpoint,
|
||||
const Endpoint& local_endpoint, const std::string& port_online_id, int port_member_id,
|
||||
uint64_t room_id, const std::optional<Endpoint>& port_endpoint) {
|
||||
LinkPtr link;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
// A peer the port asked for before it joined (a host signaling the
|
||||
// guest it summons) sits under a provisional key: keep its inner socket
|
||||
// - the port knows it there - and file it under the real id.
|
||||
for (auto it = peers_.begin(); it != peers_.end(); ++it) {
|
||||
if (!online_id.empty() && it->second->online_id == online_id && it->first != member_id) {
|
||||
LinkPtr other = it->second;
|
||||
peers_.erase(it);
|
||||
auto old = peers_.find(member_id);
|
||||
if (old != peers_.end()) {
|
||||
if (old->second != other) close_link(*old->second);
|
||||
peers_.erase(old);
|
||||
}
|
||||
other->member_id = member_id;
|
||||
other->signaling.peer_member_id = member_id > 0 ? member_id : 0;
|
||||
peers_[member_id] = other;
|
||||
break;
|
||||
}
|
||||
}
|
||||
auto it = peers_.find(member_id);
|
||||
if (it != peers_.end() && it->second->online_id == online_id) {
|
||||
Link& l = *it->second;
|
||||
if (endpoint.valid()) l.endpoint = endpoint;
|
||||
if (local_endpoint.valid()) l.local_endpoint = local_endpoint;
|
||||
if (port_endpoint && !l.port_endpoint) l.port_endpoint = port_endpoint;
|
||||
// a guest invite seeds the host before the port has joined: the room
|
||||
// and the port's member id arrive with the join
|
||||
if (port_member_id) l.signaling.port_member_id = port_member_id;
|
||||
if (room_id) l.signaling.room_id = room_id;
|
||||
return l.inner_endpoint;
|
||||
}
|
||||
if (it != peers_.end()) {
|
||||
close_link(*it->second);
|
||||
peers_.erase(it);
|
||||
}
|
||||
link = std::make_shared<Link>(PeerSignaling(online_id, member_id, port_online_id, port_member_id, room_id));
|
||||
link->member_id = member_id;
|
||||
link->online_id = online_id;
|
||||
link->endpoint = endpoint;
|
||||
link->local_endpoint = local_endpoint;
|
||||
link->port_endpoint = port_endpoint;
|
||||
link->created = now();
|
||||
link->inner = udp_bind("127.0.0.1", 0, &link->inner_endpoint);
|
||||
peers_[member_id] = link;
|
||||
}
|
||||
log(fmt("p2p: member %d (%s) at %s -> port sees 127.0.0.1:%d", member_id, online_id.c_str(),
|
||||
endpoint.valid() ? endpoint.str().c_str() : "?:0", link->inner_endpoint.port));
|
||||
return link->inner_endpoint;
|
||||
}
|
||||
|
||||
void Gateway::remove_peer(int member_id) {
|
||||
LinkPtr link;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
auto it = peers_.find(member_id);
|
||||
if (it == peers_.end()) return;
|
||||
link = it->second;
|
||||
peers_.erase(it);
|
||||
close_link(*link);
|
||||
}
|
||||
log(fmt("p2p: member %d removed (to peer %llu, to port %llu, signaling %llu)", member_id,
|
||||
static_cast<unsigned long long>(link->to_peer), static_cast<unsigned long long>(link->to_port),
|
||||
static_cast<unsigned long long>(link->signaling_count)));
|
||||
}
|
||||
|
||||
void Gateway::clear_peers() {
|
||||
std::vector<int> ids;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
for (const auto& [id, link] : peers_) ids.push_back(id);
|
||||
}
|
||||
for (int id : ids) remove_peer(id);
|
||||
}
|
||||
|
||||
void Gateway::remove_peers_except(const std::set<std::string>& online_ids) {
|
||||
std::vector<int> ids;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
for (const auto& [id, link] : peers_)
|
||||
if (!online_ids.count(link->online_id)) ids.push_back(id);
|
||||
}
|
||||
for (int id : ids) remove_peer(id);
|
||||
}
|
||||
|
||||
std::optional<Endpoint> Gateway::inner_endpoint_for(const std::string& online_id) const {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
for (const auto& [id, link] : peers_)
|
||||
if (link->online_id == online_id) return link->inner_endpoint;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::vector<PeerInfo> Gateway::peers() const {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
std::vector<PeerInfo> out;
|
||||
for (const auto& [id, l] : peers_) {
|
||||
PeerInfo p;
|
||||
p.member_id = id;
|
||||
p.online_id = l->online_id;
|
||||
p.endpoint = l->endpoint;
|
||||
p.inner = l->inner_endpoint;
|
||||
p.peer_member_id = l->signaling.peer_member_id;
|
||||
p.port_member_id = l->signaling.port_member_id;
|
||||
p.room_id = l->signaling.room_id;
|
||||
p.to_peer = l->to_peer;
|
||||
p.to_port = l->to_port;
|
||||
p.signaling = l->signaling_count;
|
||||
out.push_back(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool Gateway::relay_target(const Endpoint& e) const {
|
||||
return relay_ && e.valid() && e.ip == relay_->server.ip && e.port != relay_->server.port;
|
||||
}
|
||||
|
||||
void Gateway::send_outer(const std::string& datagram, const Endpoint& to) {
|
||||
if (!to.valid()) return;
|
||||
std::optional<Relay> relay;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
relay = relay_;
|
||||
}
|
||||
if (relay && to.ip == relay->server.ip && to.port != relay->server.port)
|
||||
udp_send(outer_, relay_wrap(relay->token, to.port, datagram), relay->server);
|
||||
else
|
||||
udp_send(outer_, datagram, to);
|
||||
}
|
||||
|
||||
void Gateway::to_port(const LinkPtr& link, const std::string& frame) {
|
||||
SOCKET s;
|
||||
std::optional<Endpoint> dst;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
s = link->inner;
|
||||
dst = link->port_endpoint;
|
||||
}
|
||||
if (s != INVALID_SOCKET && dst) udp_send(s, frame, *dst);
|
||||
}
|
||||
|
||||
Gateway::LinkPtr Gateway::link_for(const Endpoint& src) {
|
||||
for (auto& [id, link] : peers_) {
|
||||
if (src == link->endpoint || src == link->local_endpoint || link->aliases.count(src)) {
|
||||
if (src != link->endpoint && link->aliases.count(src)) link->endpoint = src; // answer where it last spoke
|
||||
return link;
|
||||
}
|
||||
}
|
||||
// A NAT may hand the peer's datagrams a new port, and a relayed bbhost peer
|
||||
// can reach us from two relay ports (the one the room lists and the one
|
||||
// signaling resolved). With one peer from that address, take the new port
|
||||
// as another of its endpoints.
|
||||
LinkPtr only;
|
||||
int same = 0;
|
||||
for (auto& [id, link] : peers_)
|
||||
if (link->endpoint.valid() && link->endpoint.ip == src.ip) {
|
||||
only = link;
|
||||
++same;
|
||||
}
|
||||
if (same != 1) return nullptr;
|
||||
only->aliases.insert(only->endpoint);
|
||||
only->aliases.insert(src);
|
||||
log(fmt("p2p: member %d (%s) also at %s", only->member_id, only->online_id.c_str(), src.str().c_str()));
|
||||
only->endpoint = src;
|
||||
return only;
|
||||
}
|
||||
|
||||
void Gateway::outer_main() {
|
||||
std::string buf(65536, '\0');
|
||||
while (!stop_) {
|
||||
sockaddr_in from{};
|
||||
socklen_t flen = sizeof(from);
|
||||
const int n = recvfrom(outer_, buf.data(), static_cast<int>(buf.size()), 0,
|
||||
reinterpret_cast<sockaddr*>(&from), &flen);
|
||||
if (n < 0) {
|
||||
if (stop_) return;
|
||||
continue;
|
||||
}
|
||||
Endpoint src = from_sockaddr(from);
|
||||
std::string data(buf.data(), static_cast<size_t>(n));
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
int port;
|
||||
std::string inner;
|
||||
if (relay_ && src == relay_->server && relay_unwrap(data, port, inner)) {
|
||||
src = Endpoint{relay_->server.ip, port};
|
||||
data = std::move(inner);
|
||||
}
|
||||
}
|
||||
if (data.size() >= 20 && data[0] == 0x01 && data[1] == 0x01) {
|
||||
on_stun(data);
|
||||
continue;
|
||||
}
|
||||
LinkPtr link;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
link = link_for(src);
|
||||
if (data.compare(0, 5, kProbe, 0, 5) == 0) {
|
||||
if (link) link->heard_peer = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
std::string frame;
|
||||
if (!link || !ps4_to_port(data, frame)) continue;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
link->heard_peer = true;
|
||||
++link->to_port;
|
||||
}
|
||||
to_port(link, frame);
|
||||
}
|
||||
}
|
||||
|
||||
void Gateway::inner_main() {
|
||||
std::string buf(65536, '\0');
|
||||
while (!stop_) {
|
||||
std::map<SOCKET, LinkPtr> links;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
for (auto& [id, link] : peers_)
|
||||
if (link->inner != INVALID_SOCKET) links[link->inner] = link;
|
||||
}
|
||||
if (links.empty()) {
|
||||
Sleep(50);
|
||||
continue;
|
||||
}
|
||||
fd_set rd;
|
||||
FD_ZERO(&rd);
|
||||
SOCKET highest = 0; // select()'s first argument: ignored on Windows, the highest fd + 1 elsewhere
|
||||
for (auto& [s, l] : links) {
|
||||
FD_SET(s, &rd);
|
||||
highest = std::max(highest, s);
|
||||
}
|
||||
timeval tv{0, 100000};
|
||||
if (select(static_cast<int>(highest) + 1, &rd, nullptr, nullptr, &tv) <= 0) continue; // a socket closed under us: rebuild
|
||||
for (auto& [s, link] : links) {
|
||||
if (!FD_ISSET(s, &rd)) continue;
|
||||
sockaddr_in from{};
|
||||
socklen_t flen = sizeof(from);
|
||||
const int n = recvfrom(s, buf.data(), static_cast<int>(buf.size()), 0,
|
||||
reinterpret_cast<sockaddr*>(&from), &flen);
|
||||
if (n < 0) continue;
|
||||
const std::string data(buf.data(), static_cast<size_t>(n));
|
||||
std::string out;
|
||||
const Frame kind = port_to_ps4(data, out);
|
||||
Endpoint peer;
|
||||
std::vector<std::string> replies;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
link->port_endpoint = from_sockaddr(from);
|
||||
if (kind == Frame::Game) {
|
||||
++link->to_peer;
|
||||
peer = link->endpoint;
|
||||
} else if (kind == Frame::Signaling) {
|
||||
++link->signaling_count;
|
||||
replies = link->signaling.handle(out);
|
||||
}
|
||||
}
|
||||
if (kind == Frame::Game) send_outer(out, peer);
|
||||
for (const auto& r : replies) to_port(link, port_signaling_frame(r));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Gateway::tick_main() {
|
||||
double last_stun = -1e9, last_punch = 0, last_opening = 0;
|
||||
while (!stop_) {
|
||||
Sleep(100);
|
||||
const double t = now();
|
||||
if (stun_server_ && t - last_stun >= stun_refresh) {
|
||||
last_stun = t;
|
||||
send_stun();
|
||||
}
|
||||
if (t - last_punch >= punch_interval) {
|
||||
last_punch = t;
|
||||
std::vector<Endpoint> targets;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
for (auto& [id, l] : peers_) {
|
||||
if (l->heard_peer || t - l->created > punch_for || relay_target(l->endpoint)) continue;
|
||||
if (l->endpoint.valid()) targets.push_back(l->endpoint);
|
||||
if (l->local_endpoint.valid()) targets.push_back(l->local_endpoint);
|
||||
}
|
||||
}
|
||||
for (const auto& e : targets) udp_send(outer_, kProbe, e);
|
||||
}
|
||||
if (t - last_opening >= opening_interval) {
|
||||
last_opening = t;
|
||||
std::vector<std::pair<LinkPtr, std::vector<std::string>>> openings;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
for (auto& [id, l] : peers_)
|
||||
if (!l->signaling.heard && t - l->created <= opening_for)
|
||||
openings.emplace_back(l, l->signaling.opening());
|
||||
}
|
||||
for (auto& [l, payloads] : openings)
|
||||
for (const auto& p : payloads) to_port(l, port_signaling_frame(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Gateway::send_stun() {
|
||||
const std::string ip = resolve_ipv4(stun_server_->first);
|
||||
if (ip.empty()) {
|
||||
log(fmt("p2p: cannot resolve STUN server %s", stun_server_->first.c_str()));
|
||||
return;
|
||||
}
|
||||
std::string request;
|
||||
Endpoint server{ip, stun_server_->second};
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
const std::string token = relay_ && relay_->server.ip == ip ? relay_->token : "";
|
||||
request = stun_request(token, stun_txid_);
|
||||
stun_server_ep_ = server;
|
||||
}
|
||||
udp_send(outer_, request, server);
|
||||
}
|
||||
|
||||
void Gateway::on_stun(const std::string& data) {
|
||||
std::optional<Endpoint> changed;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
StunAnswer a;
|
||||
if (stun_txid_.empty() || !stun_parse(data, stun_txid_, a)) return;
|
||||
if (a.relay && stun_server_ep_.valid()) {
|
||||
if (!relay_ || relay_->port != a.relay_port) log(fmt("p2p: relay on, our port on it is %d", a.relay_port));
|
||||
relay_ = Relay{stun_server_ep_, a.relay_token, a.relay_port};
|
||||
} else if (relay_) {
|
||||
log("p2p: relay off");
|
||||
relay_.reset();
|
||||
}
|
||||
if (!mapped_ || *mapped_ != a.mapped) {
|
||||
log("p2p: STUN sees us as " + a.mapped.str());
|
||||
mapped_ = a.mapped;
|
||||
changed = a.mapped;
|
||||
}
|
||||
}
|
||||
if (changed && on_mapped_change_) {
|
||||
try {
|
||||
on_mapped_change_(*changed);
|
||||
} catch (const std::exception& e) {
|
||||
log(std::string("p2p: mapped-change callback failed: ") + e.what());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace bb::p2p
|
||||
@@ -0,0 +1,163 @@
|
||||
// P2P gateway: carries the game's UDP traffic between bbport and bbhost players.
|
||||
//
|
||||
// bbport (gpu/shadps4/core/libraries/network/p2p_sockets.cpp)
|
||||
// game datagram: [src vport u16 be][dst vport u16 be][payload]
|
||||
// dst vport 0xffff: internal "SHAD" packets - 0x05/0x06 echo (17 B),
|
||||
// 0x07 handshake (62 B), 0x10 control (50 B), 0x21 matching2 handshake (50 B)
|
||||
// bbhost (src/hle/net.cpp, src/net/stun.cpp)
|
||||
// game datagram: [0xff][flags][src][dst][payload] - flags 0xC3 with one-byte
|
||||
// vports (Bloodborne's 40 and 30), 0x83 with two-byte, +4 bytes with 0x20
|
||||
// hole-punch probe "\xfebbhp\0\0\0"; STUN Binding + BBHOST-HELLO (0x8100);
|
||||
// relay: to the STUN port [0xfb]['R'][token 8][port u16][datagram],
|
||||
// from it [0xfb]['r'][source port u16][datagram]
|
||||
//
|
||||
// One OUTER socket faces the internet and speaks bbhost's format; each remote
|
||||
// member gets an INNER socket on 127.0.0.1, which is where the port is told the
|
||||
// member lives. bbport's signaling handshakes are answered on the bbhost
|
||||
// peer's behalf (bbhost has none: its connection state comes from the server).
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "util.hpp"
|
||||
|
||||
namespace bb::p2p {
|
||||
|
||||
// ---- framing (pure, unit-tested) ----
|
||||
enum class Frame { None, Game, Signaling };
|
||||
Frame port_to_ps4(const std::string& frame, std::string& out); // Game: bbhost datagram; Signaling: payload
|
||||
bool ps4_to_port(const std::string& datagram, std::string& out);
|
||||
std::string port_signaling_frame(const std::string& payload);
|
||||
std::string relay_wrap(const std::string& token, int port, const std::string& datagram);
|
||||
bool relay_unwrap(const std::string& data, int& port, std::string& datagram);
|
||||
extern const std::string kProbe;
|
||||
|
||||
struct StunAnswer {
|
||||
Endpoint mapped;
|
||||
bool relay = false;
|
||||
std::string relay_token; // 8 bytes
|
||||
int relay_port = 0;
|
||||
};
|
||||
std::string stun_request(const std::string& token, std::string& txid); // token "" = zeros
|
||||
bool stun_parse(const std::string& data, const std::string& txid, StunAnswer& out);
|
||||
|
||||
// ---- the port's signaling, answered for one bbhost peer ----
|
||||
enum : uint8_t { T_ECHO_PING = 0x05, T_ECHO_PONG = 0x06, T_HANDSHAKE = 0x07, T_CONTROL = 0x10, T_M2 = 0x21 };
|
||||
enum : uint8_t { HS_OFFER = 1, HS_ACCEPT = 2, HS_CHECK = 3, HS_CHECK_ACK = 4 };
|
||||
enum : uint8_t { CTL_ACTIVATION_REQUEST = 1, CTL_ACTIVATION_ACK = 2, CTL_CLOSE = 3, CTL_ESTABLISHED = 4 };
|
||||
|
||||
class PeerSignaling {
|
||||
public:
|
||||
PeerSignaling(const std::string& peer_online_id, int peer_member_id, const std::string& port_online_id,
|
||||
int port_member_id, uint64_t room_id);
|
||||
std::vector<std::string> handle(const std::string& payload); // replies (payloads, no frame header)
|
||||
std::vector<std::string> opening() const; // what a real peer sends first
|
||||
|
||||
std::string peer_id, port_id; // 16 bytes each, NUL padded
|
||||
int peer_member_id = 0; // 0 until the peer is a room member
|
||||
int port_member_id = 0;
|
||||
uint64_t room_id = 0;
|
||||
uint32_t conn_id = 0;
|
||||
bool sent_established = false;
|
||||
bool heard = false; // any signaling from the port for this peer
|
||||
|
||||
private:
|
||||
std::string handshake(uint8_t kind, uint32_t to_conn, uint64_t nonce) const;
|
||||
std::string control(uint8_t kind) const;
|
||||
std::string m2(uint8_t kind, uint64_t nonce) const;
|
||||
};
|
||||
|
||||
// ---- the gateway ----
|
||||
struct PeerInfo { // a snapshot, for the app and the tests
|
||||
int member_id = 0;
|
||||
std::string online_id;
|
||||
Endpoint endpoint, inner;
|
||||
int peer_member_id = 0, port_member_id = 0;
|
||||
uint64_t room_id = 0;
|
||||
uint64_t to_peer = 0, to_port = 0, signaling = 0;
|
||||
};
|
||||
|
||||
class Gateway {
|
||||
public:
|
||||
Gateway(int outer_port, std::optional<std::pair<std::string, int>> stun_server, std::string lan_addr,
|
||||
std::function<void(const Endpoint&)> on_mapped_change);
|
||||
~Gateway();
|
||||
bool start(std::string* error);
|
||||
void stop();
|
||||
|
||||
std::pair<Endpoint, Endpoint> endpoints() const; // (local, mapped) as bbhost registers them
|
||||
std::optional<Endpoint> mapped() const;
|
||||
int relay_port() const;
|
||||
|
||||
// Returns the 127.0.0.1 endpoint the port should use for this member.
|
||||
Endpoint add_peer(int member_id, const std::string& online_id, const Endpoint& endpoint,
|
||||
const Endpoint& local_endpoint, const std::string& port_online_id, int port_member_id,
|
||||
uint64_t room_id, const std::optional<Endpoint>& port_endpoint);
|
||||
void remove_peer(int member_id);
|
||||
void clear_peers();
|
||||
void remove_peers_except(const std::set<std::string>& online_ids);
|
||||
std::optional<Endpoint> inner_endpoint_for(const std::string& online_id) const;
|
||||
std::vector<PeerInfo> peers() const;
|
||||
|
||||
double punch_interval = 0.5, punch_for = 20.0, opening_interval = 1.0, opening_for = 30.0,
|
||||
stun_refresh = 15.0;
|
||||
|
||||
private:
|
||||
struct Link {
|
||||
int member_id = 0;
|
||||
std::string online_id;
|
||||
Endpoint endpoint, local_endpoint;
|
||||
std::set<Endpoint> aliases;
|
||||
std::optional<Endpoint> port_endpoint;
|
||||
PeerSignaling signaling;
|
||||
SOCKET inner = INVALID_SOCKET;
|
||||
Endpoint inner_endpoint;
|
||||
bool heard_peer = false;
|
||||
double created = 0;
|
||||
uint64_t to_peer = 0, to_port = 0, signaling_count = 0;
|
||||
Link(PeerSignaling s) : signaling(std::move(s)) {}
|
||||
};
|
||||
using LinkPtr = std::shared_ptr<Link>;
|
||||
|
||||
void outer_main();
|
||||
void inner_main();
|
||||
void tick_main();
|
||||
void send_outer(const std::string& datagram, const Endpoint& to);
|
||||
void to_port(const LinkPtr& link, const std::string& frame);
|
||||
LinkPtr link_for(const Endpoint& src); // under mu_
|
||||
bool relay_target(const Endpoint& e) const; // under mu_
|
||||
void send_stun();
|
||||
void on_stun(const std::string& data);
|
||||
static void close_link(Link& link);
|
||||
|
||||
int outer_port_ = 0;
|
||||
std::optional<std::pair<std::string, int>> stun_server_;
|
||||
std::string lan_addr_;
|
||||
std::function<void(const Endpoint&)> on_mapped_change_;
|
||||
SOCKET outer_ = INVALID_SOCKET;
|
||||
mutable std::mutex mu_;
|
||||
std::map<int, LinkPtr> peers_;
|
||||
std::optional<Endpoint> mapped_;
|
||||
struct Relay {
|
||||
Endpoint server; // its STUN endpoint
|
||||
std::string token;
|
||||
int port = 0; // ours on it
|
||||
};
|
||||
std::optional<Relay> relay_;
|
||||
std::string stun_txid_;
|
||||
Endpoint stun_server_ep_;
|
||||
std::atomic<bool> stop_{false};
|
||||
std::vector<std::thread> threads_;
|
||||
};
|
||||
|
||||
} // namespace bb::p2p
|
||||
@@ -0,0 +1,85 @@
|
||||
#include "proto3.hpp"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace pb {
|
||||
|
||||
namespace {
|
||||
bool read_varint(const std::string& b, size_t& pos, uint64_t& out) {
|
||||
out = 0;
|
||||
int shift = 0;
|
||||
while (true) {
|
||||
if (pos >= b.size() || shift > 63) return false;
|
||||
const uint8_t c = static_cast<uint8_t>(b[pos++]);
|
||||
out |= static_cast<uint64_t>(c & 0x7F) << shift;
|
||||
if (!(c & 0x80)) return true;
|
||||
shift += 7;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool parse(const std::string& b, std::vector<Field>& out) {
|
||||
size_t pos = 0;
|
||||
while (pos < b.size()) {
|
||||
uint64_t tag = 0;
|
||||
if (!read_varint(b, pos, tag)) return false;
|
||||
Field f;
|
||||
f.number = static_cast<int>(tag >> 3);
|
||||
f.wire = static_cast<int>(tag & 7);
|
||||
switch (f.wire) {
|
||||
case 0:
|
||||
if (!read_varint(b, pos, f.value)) return false;
|
||||
break;
|
||||
case 2: {
|
||||
uint64_t len = 0;
|
||||
if (!read_varint(b, pos, len) || len > b.size() - pos) return false;
|
||||
f.data.assign(b, pos, static_cast<size_t>(len));
|
||||
pos += static_cast<size_t>(len);
|
||||
break;
|
||||
}
|
||||
case 5:
|
||||
if (b.size() - pos < 4) return false;
|
||||
{ uint32_t v; std::memcpy(&v, b.data() + pos, 4); f.value = v; }
|
||||
pos += 4;
|
||||
break;
|
||||
case 1:
|
||||
if (b.size() - pos < 8) return false;
|
||||
std::memcpy(&f.value, b.data() + pos, 8);
|
||||
pos += 8;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
out.push_back(std::move(f));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
uint64_t Message::u(int field, uint64_t def) const {
|
||||
uint64_t v = def;
|
||||
for (const auto& f : fields_)
|
||||
if (f.number == field && f.wire != 2) v = f.value;
|
||||
return v;
|
||||
}
|
||||
|
||||
std::string Message::s(int field) const {
|
||||
std::string v;
|
||||
for (const auto& f : fields_)
|
||||
if (f.number == field && f.wire == 2) v = f.data;
|
||||
return v;
|
||||
}
|
||||
|
||||
bool Message::has(int field) const {
|
||||
for (const auto& f : fields_)
|
||||
if (f.number == field) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<std::string> Message::all(int field) const {
|
||||
std::vector<std::string> v;
|
||||
for (const auto& f : fields_)
|
||||
if (f.number == field && f.wire == 2) v.push_back(f.data);
|
||||
return v;
|
||||
}
|
||||
|
||||
} // namespace pb
|
||||
@@ -0,0 +1,73 @@
|
||||
// Minimal protobuf3 wire format: varint and length-delimited fields, which is
|
||||
// all the shadNet messages use. proto3 semantics: fields at their default value
|
||||
// are omitted on encode; decode keeps what is on the wire.
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace pb {
|
||||
|
||||
class Writer {
|
||||
public:
|
||||
void varint(uint64_t v) {
|
||||
while (v >= 0x80) {
|
||||
buf_.push_back(static_cast<char>((v & 0x7F) | 0x80));
|
||||
v >>= 7;
|
||||
}
|
||||
buf_.push_back(static_cast<char>(v));
|
||||
}
|
||||
void tag(int field, int wire) { varint((static_cast<uint64_t>(field) << 3) | static_cast<uint64_t>(wire)); }
|
||||
|
||||
Writer& u(int field, uint64_t v) { // uint / int (0 omitted)
|
||||
if (v) { tag(field, 0); varint(v); }
|
||||
return *this;
|
||||
}
|
||||
Writer& i(int field, int64_t v) { return u(field, static_cast<uint64_t>(v)); }
|
||||
Writer& flag(int field, bool v) { // bool (false omitted)
|
||||
if (v) { tag(field, 0); varint(1); }
|
||||
return *this;
|
||||
}
|
||||
Writer& s(int field, const std::string& v) { // string / bytes ("" omitted)
|
||||
if (!v.empty()) { tag(field, 2); varint(v.size()); buf_ += v; }
|
||||
return *this;
|
||||
}
|
||||
Writer& msg(int field, const std::string& encoded) { // embedded message, always written
|
||||
tag(field, 2);
|
||||
varint(encoded.size());
|
||||
buf_ += encoded;
|
||||
return *this;
|
||||
}
|
||||
const std::string& str() const { return buf_; }
|
||||
|
||||
private:
|
||||
std::string buf_;
|
||||
};
|
||||
|
||||
struct Field {
|
||||
int number = 0;
|
||||
int wire = 0;
|
||||
uint64_t value = 0; // varint / fixed
|
||||
std::string data; // length-delimited
|
||||
};
|
||||
|
||||
bool parse(const std::string& buf, std::vector<Field>& out);
|
||||
|
||||
// Read access by field number; scalars keep the last occurrence.
|
||||
class Message {
|
||||
public:
|
||||
Message() = default;
|
||||
explicit Message(const std::string& buf) { ok_ = parse(buf, fields_); }
|
||||
bool ok() const { return ok_; }
|
||||
uint64_t u(int field, uint64_t def = 0) const;
|
||||
std::string s(int field) const;
|
||||
bool has(int field) const;
|
||||
std::vector<std::string> all(int field) const;
|
||||
|
||||
private:
|
||||
std::vector<Field> fields_;
|
||||
bool ok_ = false;
|
||||
};
|
||||
|
||||
} // namespace pb
|
||||
@@ -0,0 +1,262 @@
|
||||
#include "setup.hpp"
|
||||
|
||||
#include <tlhelp32.h>
|
||||
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
namespace bb {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char* kBackup = ".before-bbbridge";
|
||||
constexpr const char* kMarker = "_bbbridge";
|
||||
constexpr const char* kP2pVar = "SHADPS4_P2P_PORT";
|
||||
constexpr int kP2pPort = 3658;
|
||||
constexpr const char* kBridgeServer = "127.0.0.1:31313";
|
||||
constexpr const char* kBridgeWebapi = "http://127.0.0.1:31315";
|
||||
constexpr const char* kPublicServer = "srv.shadps4.net:31313";
|
||||
constexpr const char* kPlaceholderPassword = "bbbridge"; // see enable()
|
||||
|
||||
// bbhost's play-log patch (bbhost src/engine/playlog.cpp) in bbport's patch
|
||||
// format: the float immediates of `mov dword [rip+disp], imm32` in
|
||||
// SprjPlaylogSystem's static initializer - 0x23F9B86 stores 300.0f (upload
|
||||
// period), 0x23F9B68 stores 1.5f (position period); the float is at +6.
|
||||
// Verified against eboot.bin 1.09.
|
||||
const char* kPatchXml = R"xml(<?xml version="1.0"?>
|
||||
<Patch>
|
||||
<TitleID>
|
||||
<ID>CUSA03173</ID>
|
||||
</TitleID>
|
||||
<Metadata Title="Bloodborne" Name="Live map: play log every 5 s, position every 0.5 s" Note="Uploads the play log every 5 s instead of 300 s and samples the position every 0.5 s instead of 1.5 s, as bbhost does, so the player shows on thehuntersdream.com's live map." Author="bbbridge (from bbhost)" PatchVer="1.0" AppVer="01.09" AppElf="eboot.bin" isEnabled="true">
|
||||
<PatchList>
|
||||
<Line Type="float32" Address="0x023F9B8C" Value="5.0"/>
|
||||
<Line Type="float32" Address="0x023F9B6E" Value="0.5"/>
|
||||
</PatchList>
|
||||
</Metadata>
|
||||
</Patch>
|
||||
)xml";
|
||||
|
||||
nlohmann::ordered_json overrides() {
|
||||
return {{kMarker, "Written by bbbridge: the game's server traffic goes through bbbridge. Move bbbridge's "
|
||||
"Online switch to shadNet to remove."},
|
||||
{"https://ss4.scej-network.jp:20443", "http://127.0.0.1:31316"},
|
||||
{"https://thehuntersdream.com:20443", "http://127.0.0.1:31316"},
|
||||
{"https://thehuntersdream.com:18671", "http://127.0.0.1:31317"},
|
||||
{"http://thehuntersdream.com:18671", "http://127.0.0.1:31317"},
|
||||
{"https://bb-playlog-test.s3.amazonaws.com:443", "http://127.0.0.1:31317"},
|
||||
{"https://bb-playlog-prod.s3.amazonaws.com:443", "http://127.0.0.1:31317"},
|
||||
{"https://thehuntersdream.com:18671/summon_messenger", "http://127.0.0.1:31315"},
|
||||
{"http://thehuntersdream.com:18671/summon_messenger", "http://127.0.0.1:31315"}};
|
||||
}
|
||||
|
||||
using ojson = nlohmann::ordered_json;
|
||||
|
||||
ojson read_json(const fs::path& p) {
|
||||
std::ifstream in(p);
|
||||
if (!in) return ojson();
|
||||
ojson j = ojson::parse(in, nullptr, false);
|
||||
return j.is_discarded() ? ojson() : j;
|
||||
}
|
||||
|
||||
void write_json(const fs::path& p, const ojson& j) {
|
||||
const fs::path tmp = p.wstring() + L".tmp";
|
||||
{
|
||||
std::ofstream out(tmp, std::ios::trunc);
|
||||
out << j.dump(2, ' ', false) << "\n";
|
||||
}
|
||||
MoveFileExW(tmp.c_str(), p.c_str(), MOVEFILE_REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
void backup(const fs::path& p) {
|
||||
const fs::path target = p.wstring() + widen(kBackup);
|
||||
std::error_code ec;
|
||||
if (fs::exists(p, ec) && !fs::exists(target, ec)) fs::copy_file(p, target, ec);
|
||||
}
|
||||
|
||||
std::string merge_env(const std::string& extra, const std::string& key, const std::string* value) {
|
||||
std::istringstream in(extra);
|
||||
std::string item, out;
|
||||
while (in >> item)
|
||||
if (item.rfind(key + "=", 0) != 0) out += (out.empty() ? "" : " ") + item;
|
||||
if (value) out += (out.empty() ? "" : " ") + key + "=" + *value;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Ours, or the Python bbbridge's (0.2.0: "_note": "bbbridge: ...").
|
||||
bool ours(const fs::path& overrides_file) {
|
||||
const ojson j = read_json(overrides_file);
|
||||
return j.is_object() && (j.contains(kMarker) || jstr(j, "_note").rfind("bbbridge:", 0) == 0);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool process_running(const wchar_t* image) {
|
||||
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
||||
if (snap == INVALID_HANDLE_VALUE) return false;
|
||||
PROCESSENTRY32W e{};
|
||||
e.dwSize = sizeof(e);
|
||||
bool found = false;
|
||||
for (BOOL ok = Process32FirstW(snap, &e); ok && !found; ok = Process32NextW(snap, &e))
|
||||
found = _wcsicmp(e.szExeFile, image) == 0;
|
||||
CloseHandle(snap);
|
||||
return found;
|
||||
}
|
||||
|
||||
bool launcher_running() { return process_running(L"Bloodborne.exe"); }
|
||||
bool game_running() { return process_running(L"Play Bloodborne.exe") || process_running(L"bb-probe.exe"); }
|
||||
|
||||
bool is_bbport_dir(const fs::path& dir) {
|
||||
std::error_code ec;
|
||||
return fs::is_regular_file(dir / L"Bloodborne.exe", ec) || fs::is_regular_file(dir / L"Play Bloodborne.exe", ec);
|
||||
}
|
||||
|
||||
fs::path find_bbport(const fs::path& exe_dir, const fs::path& chosen, const std::string& game_dir,
|
||||
BbportSource* source) {
|
||||
const std::pair<fs::path, BbportSource> candidates[] = {
|
||||
{chosen, BbportSource::Chosen},
|
||||
{exe_dir.parent_path(), BbportSource::ExeFolder}, // bbbridge lives in a folder inside bbport's
|
||||
{exe_dir, BbportSource::ExeFolder}, // or right next to Bloodborne.exe
|
||||
{game_dir.empty() ? fs::path() : fs::path(widen(game_dir)).parent_path(), BbportSource::Launcher}};
|
||||
for (const auto& [dir, src] : candidates)
|
||||
if (!dir.empty() && is_bbport_dir(dir)) {
|
||||
if (source) *source = src;
|
||||
return dir;
|
||||
}
|
||||
if (source) *source = BbportSource::None;
|
||||
return {};
|
||||
}
|
||||
|
||||
std::string describe(BbportSource source) {
|
||||
switch (source) {
|
||||
case BbportSource::Chosen: return "the folder you chose";
|
||||
case BbportSource::ExeFolder: return "the folder bbbridge is in";
|
||||
case BbportSource::Launcher: return "found from the bbport launcher's game folder";
|
||||
default: return "not found";
|
||||
}
|
||||
}
|
||||
|
||||
BbportSetup::BbportSetup(const fs::path& exe_dir, const fs::path& bbport_dir) : exe_dir_(exe_dir), chosen_(bbport_dir) {
|
||||
reload();
|
||||
}
|
||||
|
||||
void BbportSetup::reload() {
|
||||
wchar_t appdata[MAX_PATH] = {};
|
||||
if (!GetEnvironmentVariableW(L"APPDATA", appdata, MAX_PATH)) throw SetupError("APPDATA is not set");
|
||||
settings_file.clear();
|
||||
for (const wchar_t* name : {L"bbport-launcher", L"bbport-launcher-coop"}) {
|
||||
const fs::path p = fs::path(appdata) / name / L"settings.json";
|
||||
std::error_code ec;
|
||||
if (fs::is_regular_file(p, ec)) {
|
||||
settings_file = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (settings_file.empty())
|
||||
throw SetupError("bbport launcher settings not found. Start the bbport launcher once and close it.");
|
||||
settings = read_json(settings_file);
|
||||
if (!settings.is_object()) throw SetupError("the bbport launcher's settings.json cannot be read");
|
||||
bbport = find_bbport(exe_dir_, chosen_, jstr(settings, "game_dir"), &source);
|
||||
if (bbport.empty()) throw SetupError("bbport folder not found - choose the folder with Bloodborne.exe.");
|
||||
const std::string user = jstr(settings, "user_dir"), patches = jstr(settings, "patches_dir");
|
||||
user_dir = user.empty() ? bbport / L"user" : fs::path(widen(user));
|
||||
patches_dir = patches.empty() ? bbport / L"patches" : fs::path(widen(patches));
|
||||
}
|
||||
|
||||
bool BbportSetup::routed() const {
|
||||
return jint(settings, "online", 0) && jstr(settings, "online_server") == kBridgeServer &&
|
||||
jstr(settings, "online_webapi") == kBridgeWebapi && !jstr(settings, "online_password").empty();
|
||||
}
|
||||
|
||||
bool BbportSetup::overrides_written() const { return ours(overrides_file()); }
|
||||
|
||||
// bbport's run.py uses host_overrides.custom.json, when there is one, instead of
|
||||
// its own addresses: ours sends the game's server traffic to bbbridge, so it may
|
||||
// only be there while bbbridge runs - else the game cannot reach its server
|
||||
// ("Failed to acquire Bloodborne server information").
|
||||
void BbportSetup::set_overrides(bool on) {
|
||||
std::error_code ec;
|
||||
if (on) {
|
||||
fs::create_directories(user_dir, ec);
|
||||
if (fs::exists(overrides_file(), ec) && !ours(overrides_file())) backup(overrides_file());
|
||||
write_json(overrides_file(), overrides());
|
||||
} else if (ours(overrides_file())) {
|
||||
fs::remove(overrides_file(), ec);
|
||||
const fs::path prev = overrides_file().wstring() + widen(kBackup);
|
||||
if (fs::exists(prev, ec)) fs::rename(prev, overrides_file(), ec);
|
||||
}
|
||||
}
|
||||
|
||||
bool BbportSetup::game_ready() const {
|
||||
const std::string dir = jstr(settings, "game_dir");
|
||||
std::error_code ec;
|
||||
return !dir.empty() && fs::is_regular_file(fs::path(widen(dir)) / L"eboot.bin", ec);
|
||||
}
|
||||
|
||||
bool BbportSetup::patched() const {
|
||||
std::error_code ec;
|
||||
return fs::is_regular_file(patch_file(), ec);
|
||||
}
|
||||
|
||||
void BbportSetup::save_settings() {
|
||||
backup(settings_file);
|
||||
write_json(settings_file, settings);
|
||||
}
|
||||
|
||||
void BbportSetup::set_npid(const std::string& npid) {
|
||||
settings["online_npid"] = npid;
|
||||
save_settings();
|
||||
}
|
||||
|
||||
bool BbportSetup::shadnet_account_set() const {
|
||||
const std::string pw = jstr(settings, "online_password");
|
||||
return !npid().empty() && !pw.empty() && pw != kPlaceholderPassword;
|
||||
}
|
||||
|
||||
void BbportSetup::enable() {
|
||||
// On bbport's own server now: keep that setup (server, shadNet account) for disable(),
|
||||
// also when the player changed it since the last time.
|
||||
if (jstr(settings, "online_server") != kBridgeServer)
|
||||
write_json(settings_file.wstring() + widen(kBackup), settings);
|
||||
settings["online"] = true;
|
||||
settings["online_server"] = kBridgeServer;
|
||||
settings["online_webapi"] = kBridgeWebapi;
|
||||
// bbport does not go online without a password (np_handler.cpp: "shadNet
|
||||
// enabled but credentials missing, skipping"). The bridge never reads it; a
|
||||
// player without a shadNet account just needs something there.
|
||||
if (jstr(settings, "online_password").empty()) settings["online_password"] = kPlaceholderPassword;
|
||||
const std::string port = std::to_string(kP2pPort);
|
||||
settings["extra_env"] = merge_env(jstr(settings, "extra_env"), kP2pVar, &port);
|
||||
save_settings();
|
||||
set_overrides(true);
|
||||
}
|
||||
|
||||
void BbportSetup::disable() {
|
||||
const ojson old = read_json(settings_file.wstring() + widen(kBackup));
|
||||
std::string server = jstr(old, "online_server", kPublicServer);
|
||||
if (server.empty() || server == kBridgeServer) server = kPublicServer;
|
||||
std::string webapi = jstr(old, "online_webapi", "");
|
||||
if (webapi == kBridgeWebapi) webapi.clear();
|
||||
settings["online_server"] = server;
|
||||
settings["online_webapi"] = webapi;
|
||||
if (jhas(old, "online_npid")) settings["online_npid"] = jstr(old, "online_npid"); // the shadNet account, not The Hunter's Dream's
|
||||
if (jstr(settings, "online_password") == kPlaceholderPassword) { // ours: back to what it was
|
||||
const std::string before = jstr(old, "online_password");
|
||||
settings["online_password"] = before == kPlaceholderPassword ? "" : before;
|
||||
}
|
||||
settings["extra_env"] = merge_env(jstr(settings, "extra_env"), kP2pVar, nullptr);
|
||||
save_settings();
|
||||
set_overrides(false);
|
||||
}
|
||||
|
||||
void BbportSetup::set_patch(bool on) {
|
||||
std::error_code ec;
|
||||
if (on) {
|
||||
fs::create_directories(patches_dir, ec);
|
||||
std::ofstream(patch_file(), std::ios::trunc) << kPatchXml;
|
||||
} else {
|
||||
fs::remove(patch_file(), ec);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace bb
|
||||
@@ -0,0 +1,80 @@
|
||||
// Points an installed bbport (bloodborne_pc for Windows) at the bridge, or back.
|
||||
// Turning it on keeps bbport's own shadNet setup (server, account) in
|
||||
// settings.json.before-bbbridge; turning it off puts that back.
|
||||
// "Routed" means (a backup of every file is kept as *.before-bbbridge):
|
||||
// 1. %APPDATA%\bbport-launcher\settings.json: online on; co-op server
|
||||
// 127.0.0.1:31313; WebAPI http://127.0.0.1:31315; extra variable
|
||||
// SHADPS4_P2P_PORT=3658 (the bridge must know the port's P2P UDP port).
|
||||
// 2. <bbport user dir>\host_overrides.custom.json: the game's own server
|
||||
// traffic goes through the bridge (the account token, and the play log to
|
||||
// The Hunter's Dream instead of FromSoftware's closed upload bucket). Only
|
||||
// while bbbridge runs: the app removes it when it closes.
|
||||
// 3. <bbport>\patches\bbbridge-live-map.xml (optional): play log every 5 s,
|
||||
// position every 0.5 s - bbhost's own patch - for the site's live map.
|
||||
// The bbport launcher must be closed while settings.json changes: it rewrites
|
||||
// the file when it saves.
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
#include "util.hpp"
|
||||
|
||||
namespace bb {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
struct SetupError : std::runtime_error {
|
||||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
bool process_running(const wchar_t* image);
|
||||
bool launcher_running(); // Bloodborne.exe
|
||||
bool game_running(); // Play Bloodborne.exe / bb-probe.exe
|
||||
bool is_bbport_dir(const fs::path& dir);
|
||||
|
||||
// Where bbport was found, in this order: the folder the player chose, the
|
||||
// folder bbbridge's own folder is in (the normal install), bbbridge's own folder
|
||||
// (bbbridge.exe next to Bloodborne.exe), the launcher's game folder.
|
||||
enum class BbportSource { None, Chosen, ExeFolder, Launcher };
|
||||
fs::path find_bbport(const fs::path& exe_dir, const fs::path& chosen, const std::string& game_dir,
|
||||
BbportSource* source);
|
||||
std::string describe(BbportSource source);
|
||||
|
||||
class BbportSetup {
|
||||
public:
|
||||
// bbport_dir: "" = the folder above this exe, or from the launcher's settings.
|
||||
explicit BbportSetup(const fs::path& exe_dir, const fs::path& bbport_dir = {});
|
||||
void reload();
|
||||
|
||||
fs::path bbport, settings_file, user_dir, patches_dir;
|
||||
BbportSource source = BbportSource::None;
|
||||
nlohmann::ordered_json settings; // the launcher's file, key order kept
|
||||
|
||||
std::string npid() const { return jstr(settings, "online_npid"); }
|
||||
std::string server() const { return jstr(settings, "online_server"); }
|
||||
bool online() const { return jint(settings, "online", 0) != 0; }
|
||||
bool shadnet_account_set() const; // a name and a real (not bbbridge's placeholder) password
|
||||
bool routed() const; // bbport's settings point at bbbridge
|
||||
bool overrides_written() const; // our host_overrides.custom.json is there
|
||||
void set_overrides(bool on); // only while bbbridge runs (see setup.cpp)
|
||||
bool patched() const;
|
||||
bool game_ready() const; // the launcher has a game folder with eboot.bin
|
||||
|
||||
// the caller makes sure the launcher is closed
|
||||
void set_npid(const std::string& npid);
|
||||
void enable();
|
||||
void disable();
|
||||
void set_patch(bool on);
|
||||
|
||||
fs::path play_exe() const { return bbport / L"Play Bloodborne.exe"; }
|
||||
fs::path launcher_exe() const { return bbport / L"Bloodborne.exe"; }
|
||||
|
||||
private:
|
||||
void save_settings();
|
||||
fs::path overrides_file() const { return user_dir / L"host_overrides.custom.json"; }
|
||||
fs::path patch_file() const { return patches_dir / L"bbbridge-live-map.xml"; }
|
||||
fs::path exe_dir_, chosen_;
|
||||
};
|
||||
|
||||
} // namespace bb
|
||||
@@ -0,0 +1,103 @@
|
||||
// shadNet wire protocol: 15-byte packet framing, command / error / notification
|
||||
// numbers, and the field numbers of the messages the bridge reads or writes.
|
||||
// Sources: shadnet-p2p's protocol.h and shadnet.proto, the port's client.cpp.
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
namespace shadnet {
|
||||
|
||||
constexpr size_t kHeaderSize = 15;
|
||||
constexpr uint32_t kMaxPacketSize = 0x800000;
|
||||
constexpr uint32_t kProtocolVersion = 1;
|
||||
|
||||
enum PacketType : uint8_t { Request = 0, Reply = 1, Notification = 2, ServerInfo = 3 };
|
||||
|
||||
namespace cmd {
|
||||
constexpr uint16_t Login = 0, Terminate = 1, AddFriend = 8, RemoveFriend = 9, AddBlock = 10,
|
||||
RemoveBlock = 11, GetServerFeatures = 12, SetClientVersion = 13, GetToken = 39,
|
||||
SetAppearOffline = 40, ContextStart = 100, CreateRoom = 101, JoinRoom = 102, LeaveRoom = 103,
|
||||
SearchRoom = 104, RequestSignalingInfos = 105, ContextStop = 106,
|
||||
SetRoomDataInternal = 108, SetRoomDataExternal = 109, KickoutRoomMember = 110,
|
||||
GetWorldInfoList = 111;
|
||||
}
|
||||
|
||||
namespace err {
|
||||
constexpr uint8_t NoError = 0, Malformed = 1, Invalid = 2, InvalidInput = 3, LoginError = 5,
|
||||
LoginInvalidPassword = 8, RoomMissing = 14, NotFound = 26, Unsupported = 33;
|
||||
}
|
||||
|
||||
namespace notify {
|
||||
constexpr uint16_t RoomEvent = 10;
|
||||
}
|
||||
|
||||
namespace room_event {
|
||||
constexpr uint32_t MemberJoined = 0x1101, MemberLeft = 0x1102, KickedOut = 0x1103;
|
||||
}
|
||||
|
||||
namespace cause {
|
||||
constexpr uint32_t Leave = 1, Kickout = 2, ServerOperation = 4, MemberDisappeared = 5;
|
||||
}
|
||||
|
||||
constexpr uint32_t kOwnerFlag = 0x80000000; // SCE_NP_MATCHING2_ROOMMEMBER_FLAG_ATTR_OWNER
|
||||
|
||||
struct Header {
|
||||
uint8_t type = 0;
|
||||
uint16_t command = 0;
|
||||
uint32_t size = 0;
|
||||
uint64_t packet_id = 0;
|
||||
};
|
||||
|
||||
inline bool parse_header(const std::string& buf, Header& h) {
|
||||
if (buf.size() < kHeaderSize) return false;
|
||||
const char* p = buf.data();
|
||||
h.type = static_cast<uint8_t>(p[0]);
|
||||
std::memcpy(&h.command, p + 1, 2);
|
||||
std::memcpy(&h.size, p + 3, 4);
|
||||
std::memcpy(&h.packet_id, p + 7, 8);
|
||||
return true;
|
||||
}
|
||||
|
||||
inline std::string build_packet(uint8_t type, uint16_t command, uint64_t packet_id, const std::string& payload) {
|
||||
std::string out(kHeaderSize, '\0');
|
||||
const uint32_t size = static_cast<uint32_t>(kHeaderSize + payload.size());
|
||||
out[0] = static_cast<char>(type);
|
||||
std::memcpy(&out[1], &command, 2);
|
||||
std::memcpy(&out[3], &size, 4);
|
||||
std::memcpy(&out[7], &packet_id, 8);
|
||||
return out + payload;
|
||||
}
|
||||
|
||||
inline std::string build_server_info() {
|
||||
std::string v(4, '\0');
|
||||
std::memcpy(v.data(), &kProtocolVersion, 4);
|
||||
return build_packet(ServerInfo, 0, 0, v);
|
||||
}
|
||||
|
||||
inline std::string build_reply(uint16_t command, uint64_t packet_id, uint8_t error, const std::string& payload) {
|
||||
return build_packet(Reply, command, packet_id, std::string(1, static_cast<char>(error)) + payload);
|
||||
}
|
||||
|
||||
// Every proto payload is framed as a u32-LE length + the message.
|
||||
inline std::string proto_blob(const std::string& msg) {
|
||||
std::string out(4, '\0');
|
||||
const uint32_t n = static_cast<uint32_t>(msg.size());
|
||||
std::memcpy(out.data(), &n, 4);
|
||||
return out + msg;
|
||||
}
|
||||
|
||||
inline bool parse_proto_blob(const std::string& payload, std::string& msg) {
|
||||
if (payload.size() < 4) return false;
|
||||
uint32_t n;
|
||||
std::memcpy(&n, payload.data(), 4);
|
||||
msg = payload.substr(4, n);
|
||||
return true;
|
||||
}
|
||||
|
||||
inline std::string build_notification(uint16_t type, const std::string& msg) {
|
||||
return build_packet(Notification, type, 0, proto_blob(msg));
|
||||
}
|
||||
|
||||
} // namespace shadnet
|
||||
@@ -0,0 +1,197 @@
|
||||
#include "util.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
|
||||
#ifndef _WIN32
|
||||
#include <csignal>
|
||||
#endif
|
||||
|
||||
namespace bb {
|
||||
|
||||
namespace {
|
||||
std::mutex g_log_mu;
|
||||
std::function<void(const std::string&)> g_sink;
|
||||
std::unique_ptr<std::ofstream> g_file;
|
||||
} // namespace
|
||||
|
||||
std::string fmt(const char* format, ...) {
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
va_list copy;
|
||||
va_copy(copy, args);
|
||||
const int n = std::vsnprintf(nullptr, 0, format, copy);
|
||||
va_end(copy);
|
||||
std::string out(n > 0 ? static_cast<size_t>(n) : 0, '\0');
|
||||
if (n > 0) std::vsnprintf(out.data(), out.size() + 1, format, args);
|
||||
va_end(args);
|
||||
return out;
|
||||
}
|
||||
|
||||
void log(const std::string& msg) {
|
||||
std::time_t t = std::time(nullptr);
|
||||
std::tm tm{};
|
||||
localtime_s(&tm, &t);
|
||||
char stamp[16];
|
||||
std::strftime(stamp, sizeof(stamp), "%H:%M:%S", &tm);
|
||||
const std::string line = std::string(stamp) + " [bbbridge] " + msg;
|
||||
std::lock_guard<std::mutex> lk(g_log_mu);
|
||||
std::fwrite(line.data(), 1, line.size(), stdout);
|
||||
std::fputc('\n', stdout);
|
||||
std::fflush(stdout);
|
||||
if (g_file && *g_file) {
|
||||
*g_file << line << '\n';
|
||||
g_file->flush();
|
||||
}
|
||||
if (g_sink) g_sink(line);
|
||||
}
|
||||
|
||||
void set_log_sink(std::function<void(const std::string&)> sink) {
|
||||
std::lock_guard<std::mutex> lk(g_log_mu);
|
||||
g_sink = std::move(sink);
|
||||
}
|
||||
|
||||
void set_log_file(const std::wstring& path) {
|
||||
std::lock_guard<std::mutex> lk(g_log_mu);
|
||||
std::wstring prev = path;
|
||||
const size_t dot = prev.rfind(L".log");
|
||||
prev = (dot == std::wstring::npos ? prev : prev.substr(0, dot)) + L".prev.log";
|
||||
replace_file(path, prev);
|
||||
g_file = std::make_unique<std::ofstream>(fspath(path), std::ios::out | std::ios::trunc);
|
||||
}
|
||||
|
||||
double now() {
|
||||
using namespace std::chrono;
|
||||
return duration_cast<duration<double>>(steady_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
bool net_init() {
|
||||
static bool ok = [] {
|
||||
#ifdef _WIN32
|
||||
WSADATA wsa;
|
||||
return WSAStartup(MAKEWORD(2, 2), &wsa) == 0;
|
||||
#else
|
||||
// A send to a peer that already closed raises SIGPIPE on Linux, which ends the process;
|
||||
// Windows returns an error, which the bridge handles.
|
||||
std::signal(SIGPIPE, SIG_IGN);
|
||||
return true;
|
||||
#endif
|
||||
}();
|
||||
return ok;
|
||||
}
|
||||
|
||||
sockaddr_in to_sockaddr(const Endpoint& e) {
|
||||
sockaddr_in a{};
|
||||
a.sin_family = AF_INET;
|
||||
a.sin_port = htons(static_cast<u_short>(e.port));
|
||||
inet_pton(AF_INET, e.ip.c_str(), &a.sin_addr);
|
||||
return a;
|
||||
}
|
||||
|
||||
Endpoint from_sockaddr(const sockaddr_in& a) {
|
||||
char ip[INET_ADDRSTRLEN] = {};
|
||||
inet_ntop(AF_INET, &a.sin_addr, ip, sizeof(ip));
|
||||
return Endpoint{ip, ntohs(a.sin_port)};
|
||||
}
|
||||
|
||||
std::string detect_lan_addr() {
|
||||
// The interface the OS would route to the internet through (no packet is sent).
|
||||
SOCKET s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
|
||||
if (s == INVALID_SOCKET) return "127.0.0.1";
|
||||
sockaddr_in target = to_sockaddr(Endpoint{"8.8.8.8", 80});
|
||||
std::string out = "127.0.0.1";
|
||||
if (connect(s, reinterpret_cast<sockaddr*>(&target), sizeof(target)) == 0) {
|
||||
sockaddr_in local{};
|
||||
socklen_t len = sizeof(local);
|
||||
if (getsockname(s, reinterpret_cast<sockaddr*>(&local), &len) == 0) out = from_sockaddr(local).ip;
|
||||
}
|
||||
closesocket(s);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string resolve_ipv4(const std::string& host) {
|
||||
addrinfo hints{};
|
||||
hints.ai_family = AF_INET;
|
||||
addrinfo* res = nullptr;
|
||||
if (getaddrinfo(host.c_str(), nullptr, &hints, &res) != 0 || !res) return "";
|
||||
const std::string ip = from_sockaddr(*reinterpret_cast<sockaddr_in*>(res->ai_addr)).ip;
|
||||
freeaddrinfo(res);
|
||||
return ip;
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
std::string narrow(const std::wstring& w) {
|
||||
if (w.empty()) return {};
|
||||
const int n = WideCharToMultiByte(CP_UTF8, 0, w.data(), static_cast<int>(w.size()), nullptr, 0, nullptr, nullptr);
|
||||
std::string out(static_cast<size_t>(n), '\0');
|
||||
WideCharToMultiByte(CP_UTF8, 0, w.data(), static_cast<int>(w.size()), out.data(), n, nullptr, nullptr);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::wstring widen(const std::string& s) {
|
||||
if (s.empty()) return {};
|
||||
const int n = MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast<int>(s.size()), nullptr, 0);
|
||||
std::wstring out(static_cast<size_t>(n), L'\0');
|
||||
MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast<int>(s.size()), out.data(), n);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::filesystem::path fspath(const std::wstring& w) { return std::filesystem::path(w); }
|
||||
|
||||
bool replace_file(const std::wstring& from, const std::wstring& to) {
|
||||
return MoveFileExW(from.c_str(), to.c_str(), MOVEFILE_REPLACE_EXISTING) != 0;
|
||||
}
|
||||
#else
|
||||
// wchar_t holds a whole code point (UTF-32) on Linux.
|
||||
std::string narrow(const std::wstring& w) {
|
||||
std::string out;
|
||||
for (const wchar_t wc : w) {
|
||||
const uint32_t c = static_cast<uint32_t>(wc);
|
||||
if (c < 0x80) {
|
||||
out += static_cast<char>(c);
|
||||
} else if (c < 0x800) {
|
||||
out += static_cast<char>(0xC0 | (c >> 6));
|
||||
out += static_cast<char>(0x80 | (c & 0x3F));
|
||||
} else if (c < 0x10000) {
|
||||
out += static_cast<char>(0xE0 | (c >> 12));
|
||||
out += static_cast<char>(0x80 | ((c >> 6) & 0x3F));
|
||||
out += static_cast<char>(0x80 | (c & 0x3F));
|
||||
} else {
|
||||
out += static_cast<char>(0xF0 | (c >> 18));
|
||||
out += static_cast<char>(0x80 | ((c >> 12) & 0x3F));
|
||||
out += static_cast<char>(0x80 | ((c >> 6) & 0x3F));
|
||||
out += static_cast<char>(0x80 | (c & 0x3F));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::wstring widen(const std::string& s) {
|
||||
std::wstring out;
|
||||
for (size_t i = 0; i < s.size();) {
|
||||
const unsigned char b = static_cast<unsigned char>(s[i]);
|
||||
const int extra = b < 0x80 ? 0 : b < 0xE0 ? 1 : b < 0xF0 ? 2 : 3;
|
||||
uint32_t c = extra == 0 ? b : extra == 1 ? (b & 0x1F) : extra == 2 ? (b & 0x0F) : (b & 0x07);
|
||||
size_t j = 1;
|
||||
for (; j <= static_cast<size_t>(extra) && i + j < s.size(); ++j)
|
||||
c = (c << 6) | (static_cast<unsigned char>(s[i + j]) & 0x3F);
|
||||
out += static_cast<wchar_t>(c);
|
||||
i += j;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::filesystem::path fspath(const std::wstring& w) { return std::filesystem::path(narrow(w)); }
|
||||
|
||||
bool replace_file(const std::wstring& from, const std::wstring& to) {
|
||||
return std::rename(narrow(from).c_str(), narrow(to).c_str()) == 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace bb
|
||||
@@ -0,0 +1,126 @@
|
||||
// Shared helpers: logging, endpoints, Winsock, tolerant JSON field access.
|
||||
#pragma once
|
||||
|
||||
#ifdef _WIN32
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <winsock2.h>
|
||||
#include <ws2tcpip.h>
|
||||
#include <windows.h>
|
||||
#else
|
||||
// Linux: BSD sockets under Winsock's names, so the bridge's code is the same on both.
|
||||
#include <arpa/inet.h>
|
||||
#include <netdb.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netinet/tcp.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <ctime>
|
||||
#include <thread>
|
||||
|
||||
using SOCKET = int;
|
||||
constexpr SOCKET INVALID_SOCKET = -1;
|
||||
constexpr int SD_BOTH = SHUT_RDWR;
|
||||
// Closing a socket does not wake a thread blocked in accept() or recvfrom() on it on Linux
|
||||
// (Windows does): shutdown() first, which does, also for listening and unconnected UDP sockets.
|
||||
inline int closesocket(SOCKET s) {
|
||||
::shutdown(s, SHUT_RDWR);
|
||||
return ::close(s);
|
||||
}
|
||||
inline int WSAGetLastError() { return errno; }
|
||||
inline void Sleep(unsigned ms) { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); }
|
||||
inline void localtime_s(std::tm* out, const std::time_t* t) { localtime_r(t, out); }
|
||||
#endif
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
#include "nlohmann/json.hpp"
|
||||
|
||||
namespace bb {
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
constexpr const char* kVersion = "0.3.2";
|
||||
|
||||
// ---- logging: "HH:MM:SS [bbbridge] msg" to stdout, the log file and the app ----
|
||||
void log(const std::string& msg);
|
||||
std::string fmt(const char* format, ...);
|
||||
void set_log_sink(std::function<void(const std::string&)> sink); // the app's log panel
|
||||
void set_log_file(const std::wstring& path); // keeps the previous as *.prev.log
|
||||
|
||||
double now(); // seconds, monotonic
|
||||
|
||||
// ---- endpoints ----
|
||||
struct Endpoint {
|
||||
std::string ip;
|
||||
int port = 0;
|
||||
bool valid() const { return !ip.empty() && port > 0 && port <= 65535; }
|
||||
bool operator==(const Endpoint& o) const { return ip == o.ip && port == o.port; }
|
||||
bool operator!=(const Endpoint& o) const { return !(*this == o); }
|
||||
bool operator<(const Endpoint& o) const { return ip != o.ip ? ip < o.ip : port < o.port; }
|
||||
std::string str() const { return ip + ":" + std::to_string(port); }
|
||||
};
|
||||
|
||||
bool net_init();
|
||||
sockaddr_in to_sockaddr(const Endpoint& e);
|
||||
Endpoint from_sockaddr(const sockaddr_in& a);
|
||||
std::string detect_lan_addr();
|
||||
std::string resolve_ipv4(const std::string& host); // "" when it fails
|
||||
|
||||
// ---- JSON as Python's dict.get(): tolerant of missing keys, null, numbers as strings ----
|
||||
// Templates, so they serve json and ordered_json (settings files keep their key order).
|
||||
template <typename J>
|
||||
bool jhas(const J& j, const char* key) { // present and not null
|
||||
if (!j.is_object()) return false;
|
||||
auto it = j.find(key);
|
||||
return it != j.end() && !it->is_null();
|
||||
}
|
||||
|
||||
template <typename J>
|
||||
int64_t jint(const J& j, const char* key, int64_t def = 0) {
|
||||
if (!jhas(j, key)) return def;
|
||||
const auto& v = j.at(key);
|
||||
try {
|
||||
if (v.is_number_integer()) return v.template get<int64_t>();
|
||||
if (v.is_number_unsigned()) return static_cast<int64_t>(v.template get<uint64_t>());
|
||||
if (v.is_number_float()) return static_cast<int64_t>(v.template get<double>());
|
||||
if (v.is_boolean()) return v.template get<bool>() ? 1 : 0;
|
||||
if (v.is_string()) {
|
||||
const std::string s = v.template get<std::string>();
|
||||
return s.empty() ? def : std::stoll(s);
|
||||
}
|
||||
} catch (...) {
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
template <typename J>
|
||||
std::string jstr(const J& j, const char* key, const std::string& def = "") {
|
||||
if (!jhas(j, key)) return def;
|
||||
const auto& v = j.at(key);
|
||||
if (v.is_string()) return v.template get<std::string>();
|
||||
if (v.is_number_integer()) return std::to_string(v.template get<int64_t>());
|
||||
if (v.is_number_unsigned()) return std::to_string(v.template get<uint64_t>());
|
||||
return v.dump();
|
||||
}
|
||||
|
||||
std::string narrow(const std::wstring& w);
|
||||
std::wstring widen(const std::string& s);
|
||||
// A file name kept as UTF-16/32 text (std::wstring) as a path to open: on Linux file names are bytes,
|
||||
// UTF-8 here.
|
||||
std::filesystem::path fspath(const std::wstring& w);
|
||||
// Replaces `to` with `from` in one step (MoveFileExW with MOVEFILE_REPLACE_EXISTING, rename()).
|
||||
bool replace_file(const std::wstring& from, const std::wstring& to);
|
||||
|
||||
} // namespace bb
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Minimal protobuf3 wire-format codec (no dependencies).
|
||||
|
||||
Only what shadnet_proto.py needs: varint and length-delimited fields.
|
||||
proto3 semantics: fields at their default value are omitted on encode;
|
||||
decode returns only the fields present on the wire.
|
||||
|
||||
Schema entry: field_number -> (name, kind)
|
||||
kind: "varint" (int), "bool", "string", "bytes", or ("message", schema).
|
||||
A wire field may repeat; decoded values are then lists (never for our
|
||||
scalars in practice, but decode keeps the last occurrence for scalars and
|
||||
extends a list for messages, which is what the shadnet messages use).
|
||||
"""
|
||||
|
||||
import struct
|
||||
|
||||
|
||||
def encode_varint(value):
|
||||
if value < 0:
|
||||
# int32/int64 negatives are encoded as 10-byte two's complement
|
||||
value &= (1 << 64) - 1
|
||||
out = bytearray()
|
||||
while True:
|
||||
b = value & 0x7F
|
||||
value >>= 7
|
||||
if value:
|
||||
out.append(b | 0x80)
|
||||
else:
|
||||
out.append(b)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def decode_varint(buf, pos):
|
||||
result = 0
|
||||
shift = 0
|
||||
while True:
|
||||
if pos >= len(buf):
|
||||
raise ValueError("varint runs past end of buffer")
|
||||
b = buf[pos]
|
||||
pos += 1
|
||||
result |= (b & 0x7F) << shift
|
||||
if not (b & 0x80):
|
||||
return result, pos
|
||||
shift += 7
|
||||
if shift > 70:
|
||||
raise ValueError("varint too long")
|
||||
|
||||
|
||||
def encode_tag(field_number, wire_type):
|
||||
return encode_varint((field_number << 3) | wire_type)
|
||||
|
||||
|
||||
def encode_field(field_number, kind, value):
|
||||
"""Encode one field occurrence. Returns bytes."""
|
||||
if kind == "varint":
|
||||
if value in (0, None):
|
||||
return b""
|
||||
return encode_tag(field_number, 0) + encode_varint(int(value))
|
||||
if kind == "bool":
|
||||
if not value:
|
||||
return b""
|
||||
return encode_tag(field_number, 0) + encode_varint(1)
|
||||
if kind in ("string", "bytes"):
|
||||
if not value:
|
||||
return b""
|
||||
data = value.encode("utf-8") if kind == "string" else bytes(value)
|
||||
return encode_tag(field_number, 2) + encode_varint(len(data)) + data
|
||||
if isinstance(kind, tuple) and kind[0] == "message":
|
||||
if value is None:
|
||||
return b""
|
||||
data = encode_message(kind[1], value)
|
||||
return encode_tag(field_number, 2) + encode_varint(len(data)) + data
|
||||
raise ValueError("unknown kind %r" % (kind,))
|
||||
|
||||
|
||||
def encode_message(schema, values):
|
||||
"""values: {field_number: value}. Repeated messages take a list."""
|
||||
out = bytearray()
|
||||
for field_number in sorted(values):
|
||||
name, kind = schema[field_number]
|
||||
value = values[field_number]
|
||||
if isinstance(kind, tuple) and kind[0] == "repeated":
|
||||
_, inner = kind
|
||||
for item in (value or []):
|
||||
out += encode_field(field_number, inner, item)
|
||||
else:
|
||||
out += encode_field(field_number, kind, value)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def decode_raw_fields(buf):
|
||||
"""Yield (field_number, wire_type, value) for every field on the wire.
|
||||
Length-delimited values are bytes; varints are ints."""
|
||||
pos = 0
|
||||
n = len(buf)
|
||||
while pos < n:
|
||||
tag, pos = decode_varint(buf, pos)
|
||||
field_number = tag >> 3
|
||||
wire_type = tag & 7
|
||||
if wire_type == 0:
|
||||
value, pos = decode_varint(buf, pos)
|
||||
elif wire_type == 2:
|
||||
length, pos = decode_varint(buf, pos)
|
||||
value = buf[pos:pos + length]
|
||||
if len(value) != length:
|
||||
raise ValueError("length-delimited field runs past end")
|
||||
pos += length
|
||||
elif wire_type == 5: # fixed32
|
||||
value = struct.unpack_from("<I", buf, pos)[0]
|
||||
pos += 4
|
||||
elif wire_type == 1: # fixed64
|
||||
value = struct.unpack_from("<Q", buf, pos)[0]
|
||||
pos += 8
|
||||
else:
|
||||
raise ValueError("unsupported wire type %d" % wire_type)
|
||||
yield field_number, wire_type, value
|
||||
|
||||
|
||||
def decode_message(schema, buf):
|
||||
"""Decode into {name: value}. Scalars keep the last occurrence; repeated
|
||||
messages accumulate into lists; nested messages are decoded recursively.
|
||||
Unknown fields are kept under name "<field:N>" as raw bytes/int."""
|
||||
out = {}
|
||||
for field_number, wire_type, value in decode_raw_fields(buf):
|
||||
entry = schema.get(field_number)
|
||||
if entry is None:
|
||||
name = "<field:%d>" % field_number
|
||||
out[name] = value
|
||||
continue
|
||||
name, kind = entry
|
||||
if isinstance(kind, tuple) and kind[0] == "repeated":
|
||||
_, inner = kind
|
||||
if isinstance(inner, tuple):
|
||||
item = decode_message(inner[1], value)
|
||||
elif inner == "string":
|
||||
item = value.decode("utf-8", "replace")
|
||||
else:
|
||||
item = value
|
||||
out.setdefault(name, []).append(item)
|
||||
elif isinstance(kind, tuple) and kind[0] == "message":
|
||||
out[name] = decode_message(kind[1], value)
|
||||
elif kind == "string":
|
||||
out[name] = value.decode("utf-8", "replace") if isinstance(value, (bytes, bytearray)) else value
|
||||
elif kind == "bool":
|
||||
out[name] = bool(value)
|
||||
else: # varint
|
||||
out[name] = value
|
||||
return out
|
||||
@@ -0,0 +1,395 @@
|
||||
"""shadNet wire protocol: 15-byte packet framing + message schemas.
|
||||
|
||||
Sources (read from the actual code, not the stale docs):
|
||||
- Packet layout: shadnet-p2p-main/documentation/protocol.md
|
||||
- Command IDs: shadnet-p2p-main/src/protocol.h (== the port's client.h;
|
||||
matching commands live at 100-115, NOT 12-23)
|
||||
- Message fields: the port's gpu/shadps4/shadnet/shadnet.proto (subset used
|
||||
by the client) cross-checked against shadnet-p2p's fuller proto.
|
||||
"""
|
||||
|
||||
import struct
|
||||
|
||||
import proto3
|
||||
|
||||
HEADER_SIZE = 15
|
||||
MAX_PACKET_SIZE = 0x800000
|
||||
PROTOCOL_VERSION = 1
|
||||
|
||||
PACKET_REQUEST = 0
|
||||
PACKET_REPLY = 1
|
||||
PACKET_NOTIFICATION = 2
|
||||
PACKET_SERVER_INFO = 3
|
||||
|
||||
|
||||
class Command:
|
||||
Login = 0
|
||||
Terminate = 1
|
||||
Create = 2
|
||||
Delete = 3
|
||||
AddFriend = 8
|
||||
RemoveFriend = 9
|
||||
AddBlock = 10
|
||||
RemoveBlock = 11
|
||||
GetServerFeatures = 12
|
||||
SetClientVersion = 13
|
||||
GetToken = 39
|
||||
SetAppearOffline = 40
|
||||
ContextStart = 100
|
||||
CreateRoom = 101
|
||||
JoinRoom = 102
|
||||
LeaveRoom = 103
|
||||
SearchRoom = 104
|
||||
RequestSignalingInfos = 105
|
||||
ContextStop = 106
|
||||
SetUserInfo = 107
|
||||
SetRoomDataInternal = 108
|
||||
SetRoomDataExternal = 109
|
||||
KickoutRoomMember = 110
|
||||
GetWorldInfoList = 111
|
||||
GetRoomDataExternalList = 112
|
||||
GetUserInfoList = 113
|
||||
GetRoomMemberDataExternalList = 114
|
||||
SendRoomMessage = 115
|
||||
GetBoardInfos = 30
|
||||
RecordScore = 31
|
||||
RecordScoreData = 32
|
||||
GetScoreData = 33
|
||||
GetScoreRange = 34
|
||||
GetScoreFriends = 35
|
||||
GetScoreNpid = 36
|
||||
|
||||
|
||||
class Error:
|
||||
NoError = 0
|
||||
Malformed = 1
|
||||
Invalid = 2
|
||||
InvalidInput = 3
|
||||
TooSoon = 4
|
||||
LoginError = 5
|
||||
LoginAlreadyLoggedIn = 6
|
||||
LoginInvalidPassword = 8
|
||||
LoginInvalidToken = 9
|
||||
RoomMissing = 14
|
||||
RoomAlreadyJoined = 15
|
||||
RoomFull = 16
|
||||
Unauthorized = 23
|
||||
NotFound = 26
|
||||
Unsupported = 33
|
||||
|
||||
|
||||
class Notify:
|
||||
FriendQuery = 5
|
||||
FriendNew = 6
|
||||
FriendLost = 7
|
||||
FriendStatus = 8
|
||||
RoomEvent = 10
|
||||
RoomMessage = 11
|
||||
|
||||
|
||||
# Orbis room event ids (bbhost np_matching2.cpp + shadnet-p2p matching.md)
|
||||
class RoomEvent:
|
||||
MemberJoined = 0x1101
|
||||
MemberLeft = 0x1102
|
||||
KickedOut = 0x1103
|
||||
RoomDestroyed = 0x1104
|
||||
RoomDataUpdatedInternal = 0x1106
|
||||
|
||||
|
||||
class EventCause:
|
||||
Leave = 1
|
||||
Kickout = 2
|
||||
ServerOperation = 4
|
||||
MemberDisappeared = 5
|
||||
|
||||
|
||||
# ---- protobuf message schemas (field number -> (name, kind)) ----------------
|
||||
|
||||
MATCHING_BIN_ATTR = {
|
||||
1: ("attr_id", "varint"),
|
||||
2: ("data", "bytes"),
|
||||
3: ("update_date", "varint"),
|
||||
4: ("update_member_id", "varint"),
|
||||
}
|
||||
|
||||
MATCHING_INT_ATTR = {
|
||||
1: ("attr_id", "varint"),
|
||||
2: ("attr_value", "varint"),
|
||||
}
|
||||
|
||||
MATCHING_ROOM_GROUP = {
|
||||
1: ("group_id", "varint"),
|
||||
2: ("has_passwd", "bool"),
|
||||
3: ("has_label", "bool"),
|
||||
4: ("label", "bytes"),
|
||||
5: ("slot_count", "varint"),
|
||||
6: ("num_members", "varint"),
|
||||
}
|
||||
|
||||
MATCHING_ROOM_MEMBER_DATA = {
|
||||
1: ("npid", "string"),
|
||||
2: ("member_id", "varint"),
|
||||
3: ("team_id", "varint"),
|
||||
4: ("is_owner", "bool"),
|
||||
5: ("group", ("message", MATCHING_ROOM_GROUP)),
|
||||
6: ("bin_attrs_internal", ("repeated", ("message", MATCHING_BIN_ATTR))),
|
||||
7: ("join_date", "varint"),
|
||||
8: ("nat_type", "varint"),
|
||||
9: ("flag_attr", "varint"),
|
||||
10: ("addr", "string"),
|
||||
11: ("port", "varint"),
|
||||
12: ("group_id", "varint"),
|
||||
13: ("account_id", "varint"),
|
||||
14: ("platform", "varint"),
|
||||
}
|
||||
|
||||
MATCHING_ROOM_DATA_INTERNAL = {
|
||||
1: ("public_slots", "varint"),
|
||||
2: ("private_slots", "varint"),
|
||||
3: ("open_public_slots", "varint"),
|
||||
4: ("open_private_slots", "varint"),
|
||||
5: ("max_slot", "varint"),
|
||||
6: ("server_id", "varint"),
|
||||
7: ("world_id", "varint"),
|
||||
8: ("lobby_id", "varint"),
|
||||
9: ("room_id", "varint"),
|
||||
10: ("passwd_slot_mask", "varint"),
|
||||
11: ("joined_slot_mask", "varint"),
|
||||
12: ("groups", ("repeated", ("message", MATCHING_ROOM_GROUP))),
|
||||
13: ("flags", "varint"),
|
||||
14: ("bin_attrs_internal", ("repeated", ("message", MATCHING_BIN_ATTR))),
|
||||
}
|
||||
|
||||
CREATE_JOIN_ROOM_RESPONSE = {
|
||||
1: ("room_data", ("message", MATCHING_ROOM_DATA_INTERNAL)),
|
||||
2: ("members", ("repeated", ("message", MATCHING_ROOM_MEMBER_DATA))),
|
||||
3: ("me_member_id", "varint"),
|
||||
4: ("owner_member_id", "varint"),
|
||||
}
|
||||
|
||||
MATCHING_WORLD = {
|
||||
1: ("world_id", "varint"),
|
||||
2: ("lobbies_num", "varint"),
|
||||
3: ("max_lobby_members", "varint"),
|
||||
4: ("lobby_members_num", "varint"),
|
||||
5: ("rooms_num", "varint"),
|
||||
6: ("room_members_num", "varint"),
|
||||
}
|
||||
|
||||
LOGIN_REQUEST = {
|
||||
1: ("npid", "string"),
|
||||
2: ("password", "string"),
|
||||
3: ("token", "string"),
|
||||
4: ("title_id", "string"),
|
||||
5: ("title_name", "string"),
|
||||
6: ("appear_offline", "bool"),
|
||||
}
|
||||
|
||||
LOGIN_REPLY = {
|
||||
1: ("avatar_url", "string"),
|
||||
2: ("user_id", "varint"),
|
||||
3: ("friends", ("repeated", ("message", {
|
||||
1: ("npid", "string"),
|
||||
2: ("online", "bool"),
|
||||
3: ("presence", "bytes"),
|
||||
}))),
|
||||
4: ("friend_requests_sent", ("repeated", "string")),
|
||||
5: ("friend_requests_received", ("repeated", "string")),
|
||||
6: ("blocked", ("repeated", "string")),
|
||||
}
|
||||
|
||||
SERVER_FEATURES_REPLY = {1: ("matching2_enabled", "bool")}
|
||||
|
||||
GET_TOKEN_REPLY = {
|
||||
1: ("token", "string"),
|
||||
2: ("user_id", "varint"),
|
||||
3: ("npid", "string"),
|
||||
}
|
||||
|
||||
CONTEXT_START_REQUEST = {1: ("ctx_id", "varint")}
|
||||
CONTEXT_STOP_REQUEST = {1: ("ctx_id", "varint")}
|
||||
|
||||
CREATE_ROOM_REQUEST = {
|
||||
1: ("req_id", "varint"),
|
||||
2: ("max_slots", "varint"),
|
||||
3: ("team_id", "varint"),
|
||||
4: ("world_id", "varint"),
|
||||
5: ("lobby_id", "varint"),
|
||||
6: ("flags", "varint"),
|
||||
7: ("group_config_count", "varint"),
|
||||
8: ("allowed_user_count", "varint"),
|
||||
9: ("blocked_user_count", "varint"),
|
||||
10: ("internal_bin_attr_count", "varint"),
|
||||
11: ("external_search_int_attrs", ("repeated", ("message", MATCHING_INT_ATTR))),
|
||||
12: ("external_search_bin_attrs", ("repeated", ("message", MATCHING_BIN_ATTR))),
|
||||
13: ("external_bin_attrs", ("repeated", ("message", MATCHING_BIN_ATTR))),
|
||||
14: ("member_bin_attrs", ("repeated", ("message", MATCHING_BIN_ATTR))),
|
||||
15: ("join_group_label_present", "bool"),
|
||||
16: ("room_password_present", "bool"),
|
||||
17: ("sig_type", "varint"),
|
||||
18: ("sig_flag", "varint"),
|
||||
19: ("sig_main_member", "varint"),
|
||||
20: ("internal_bin_attrs", ("repeated", ("message", MATCHING_BIN_ATTR))),
|
||||
21: ("user_id_kind", "varint"),
|
||||
22: ("allowed_online_ids", ("repeated", "string")),
|
||||
23: ("blocked_online_ids", ("repeated", "string")),
|
||||
24: ("allowed_account_ids", ("repeated", "varint")),
|
||||
25: ("blocked_account_ids", ("repeated", "varint")),
|
||||
26: ("group_configs", ("repeated", ("message", {
|
||||
1: ("slot_count", "varint"),
|
||||
2: ("has_label", "bool"),
|
||||
3: ("label", "bytes"),
|
||||
4: ("has_passwd", "bool"),
|
||||
}))),
|
||||
27: ("room_password", "bytes"),
|
||||
28: ("has_passwd_slot_mask", "bool"),
|
||||
29: ("passwd_slot_mask", "varint"),
|
||||
30: ("join_group_label", "bytes"),
|
||||
}
|
||||
|
||||
CREATE_ROOM_REPLY = {
|
||||
1: ("room_id", "varint"),
|
||||
2: ("server_id", "varint"),
|
||||
3: ("world_id", "varint"),
|
||||
4: ("lobby_id", "varint"),
|
||||
5: ("member_id", "varint"),
|
||||
6: ("max_slots", "varint"),
|
||||
7: ("flags", "varint"),
|
||||
8: ("cur_members", "varint"),
|
||||
9: ("details", ("message", CREATE_JOIN_ROOM_RESPONSE)),
|
||||
}
|
||||
|
||||
JOIN_ROOM_REQUEST = {
|
||||
1: ("room_id", "varint"),
|
||||
2: ("req_id", "varint"),
|
||||
3: ("team_id", "varint"),
|
||||
4: ("join_flags", "varint"),
|
||||
5: ("blocked_user_count", "varint"),
|
||||
6: ("member_bin_attrs", ("repeated", ("message", MATCHING_BIN_ATTR))),
|
||||
7: ("room_password_present", "bool"),
|
||||
8: ("join_group_label_present", "bool"),
|
||||
9: ("user_id_kind", "varint"),
|
||||
10: ("blocked_online_ids", ("repeated", "string")),
|
||||
11: ("blocked_account_ids", ("repeated", "varint")),
|
||||
}
|
||||
|
||||
JOIN_ROOM_REPLY = {
|
||||
1: ("room_id", "varint"),
|
||||
2: ("member_id", "varint"),
|
||||
3: ("max_slots", "varint"),
|
||||
4: ("flags", "varint"),
|
||||
5: ("cur_members", "varint"),
|
||||
6: ("details", ("message", CREATE_JOIN_ROOM_RESPONSE)),
|
||||
}
|
||||
|
||||
LEAVE_ROOM_REQUEST = {1: ("room_id", "varint"), 2: ("req_id", "varint")}
|
||||
LEAVE_ROOM_REPLY = {1: ("room_id", "varint")}
|
||||
|
||||
REQUEST_SIGNALING_INFOS_REQUEST = {1: ("target_npid", "string")}
|
||||
REQUEST_SIGNALING_INFOS_REPLY = {
|
||||
1: ("target_npid", "string"),
|
||||
2: ("target_ip", "string"),
|
||||
3: ("target_port", "varint"),
|
||||
4: ("target_member_id", "varint"),
|
||||
}
|
||||
|
||||
SET_ROOM_DATA_INTERNAL_REQUEST = {
|
||||
1: ("req_id", "varint"),
|
||||
2: ("room_id", "varint"),
|
||||
3: ("flag_filter", "varint"),
|
||||
4: ("flag_attr", "varint"),
|
||||
5: ("bin_attrs", ("repeated", ("message", MATCHING_BIN_ATTR))),
|
||||
6: ("has_passwd_mask", "bool"),
|
||||
7: ("passwd_slot_mask", "varint"),
|
||||
}
|
||||
SET_ROOM_DATA_INTERNAL_REPLY = {1: ("room_id", "varint")}
|
||||
|
||||
SET_ROOM_DATA_EXTERNAL_REQUEST = {
|
||||
1: ("req_id", "varint"),
|
||||
2: ("room_id", "varint"),
|
||||
3: ("search_int_attrs", ("repeated", ("message", MATCHING_INT_ATTR))),
|
||||
4: ("search_bin_attrs", ("repeated", ("message", MATCHING_BIN_ATTR))),
|
||||
5: ("ext_bin_attrs", ("repeated", ("message", MATCHING_BIN_ATTR))),
|
||||
}
|
||||
SET_ROOM_DATA_EXTERNAL_REPLY = {1: ("room_id", "varint")}
|
||||
|
||||
KICKOUT_ROOM_MEMBER_REQUEST = {
|
||||
1: ("room_id", "varint"),
|
||||
2: ("req_id", "varint"),
|
||||
3: ("target_member_id", "varint"),
|
||||
4: ("block_kick_flag", "varint"),
|
||||
}
|
||||
KICKOUT_ROOM_MEMBER_REPLY = {1: ("room_id", "varint")}
|
||||
|
||||
SEARCH_ROOM_REPLY = {
|
||||
1: ("rooms", ("repeated", ("message", {
|
||||
1: ("max_slot", "varint"),
|
||||
2: ("cur_members", "varint"),
|
||||
3: ("flags", "varint"),
|
||||
4: ("server_id", "varint"),
|
||||
5: ("world_id", "varint"),
|
||||
6: ("lobby_id", "varint"),
|
||||
7: ("room_id", "varint"),
|
||||
}))),
|
||||
2: ("range_start", "varint"),
|
||||
3: ("range_total", "varint"),
|
||||
4: ("range_result", "varint"),
|
||||
}
|
||||
|
||||
GET_WORLD_INFO_LIST_REQUEST = {1: ("server_id", "varint")}
|
||||
GET_WORLD_INFO_LIST_REPLY = {1: ("worlds", ("repeated", ("message", MATCHING_WORLD)))}
|
||||
|
||||
FRIEND_COMMAND_REQUEST = {1: ("npid", "string")}
|
||||
|
||||
NOTIFY_ROOM_EVENT = {
|
||||
1: ("ctx_id", "varint"),
|
||||
2: ("room_id", "varint"),
|
||||
3: ("event", "varint"),
|
||||
4: ("event_cause", "varint"),
|
||||
5: ("error_code", "varint"),
|
||||
6: ("member", ("message", MATCHING_ROOM_MEMBER_DATA)),
|
||||
7: ("bin_attrs", ("repeated", ("message", MATCHING_BIN_ATTR))),
|
||||
8: ("flags", "varint"),
|
||||
9: ("has_passwd_mask", "bool"),
|
||||
10: ("passwd_slot_mask", "varint"),
|
||||
}
|
||||
|
||||
SET_APPEAR_OFFLINE_REQUEST = {1: ("appear_offline", "bool")}
|
||||
|
||||
|
||||
# ---- packet framing ---------------------------------------------------------
|
||||
|
||||
def build_packet(packet_type, command, packet_id, payload=b""):
|
||||
size = HEADER_SIZE + len(payload)
|
||||
return struct.pack("<BHIQ", packet_type, command, size, packet_id) + payload
|
||||
|
||||
|
||||
def build_server_info():
|
||||
return build_packet(PACKET_SERVER_INFO, 0, 0, struct.pack("<I", PROTOCOL_VERSION))
|
||||
|
||||
|
||||
def build_reply(command, packet_id, error, payload=b""):
|
||||
return build_packet(PACKET_REPLY, command, packet_id, bytes([error]) + payload)
|
||||
|
||||
|
||||
def build_notification(notification_type, payload):
|
||||
return build_packet(PACKET_NOTIFICATION, notification_type, 0, payload)
|
||||
|
||||
|
||||
def proto_blob(message_bytes):
|
||||
"""The u32-LE length prefix + message framing every proto payload uses."""
|
||||
return struct.pack("<I", len(message_bytes)) + message_bytes
|
||||
|
||||
|
||||
def parse_proto_blob(payload):
|
||||
if len(payload) < 4:
|
||||
return None
|
||||
(size,) = struct.unpack_from("<I", payload, 0)
|
||||
return payload[4:4 + size]
|
||||
|
||||
|
||||
def parse_header(buf):
|
||||
if len(buf) < HEADER_SIZE:
|
||||
return None
|
||||
packet_type, command, size, packet_id = struct.unpack_from("<BHIQ", buf, 0)
|
||||
return packet_type, command, size, packet_id
|
||||
@@ -0,0 +1,778 @@
|
||||
#!/usr/bin/env python3
|
||||
"""End-to-end tests for the C++ bridge (bin/bbbridge-cli.exe).
|
||||
|
||||
The same checks the Python bridge passed (its test_offline.py and test_p2p.py),
|
||||
run against the C++ build over real sockets: a fake bbhost server, a fake
|
||||
STUN/relay server, a shadNet client, fake port and peer UDP sockets. The
|
||||
protobuf / shadNet codecs used to read the bridge's answers are the Python
|
||||
bridge's own (proto3.py, shadnet_proto.py) - an independent implementation.
|
||||
|
||||
python tests/e2e/test_e2e.py [path\\to\\bbbridge-cli.exe] (Linux: the path to bbbridge-cli)
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
import proto3 # noqa: E402
|
||||
import shadnet_proto as sp # noqa: E402
|
||||
|
||||
EXE = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "..", "..", "bin", "bbbridge-cli.exe")
|
||||
HOST = "127.0.0.1"
|
||||
NPID, HOST_NPID = "tester01", "hostplayer"
|
||||
TIMEOUT = 5.0
|
||||
PASSED, FAILED = [], []
|
||||
|
||||
|
||||
def check(name, cond, detail=""):
|
||||
(PASSED if cond else FAILED).append((name, detail))
|
||||
print(" [%s] %s%s" % ("PASS" if cond else "FAIL", name, "" if cond else " " + str(detail)))
|
||||
|
||||
|
||||
def account_id_for(npid):
|
||||
d = hashlib.sha256(("bbbridge:" + npid).encode()).digest()
|
||||
return (1 << 52) | (int.from_bytes(d[:8], "big") & ((1 << 48) - 1))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- fake bbhost
|
||||
|
||||
class FakeBBHost(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def _json(self, obj, status=200):
|
||||
body = json.dumps(obj).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_POST(self):
|
||||
n = int(self.headers.get("Content-Length", 0) or 0)
|
||||
try:
|
||||
body = json.loads(self.rfile.read(n).decode()) if n else {}
|
||||
except ValueError:
|
||||
body = {}
|
||||
path = self.path.split("?")[0]
|
||||
st = self.server.state
|
||||
with st["lock"]:
|
||||
st["calls"].append(path)
|
||||
emit = st["emit_event"]
|
||||
event = st.get("event")
|
||||
r = {"ResKind": 0}
|
||||
if path == "/auth/account/create":
|
||||
r.update(Token="tok-123", OnlineId=body.get("Name"), RecoveryCode="RC-42")
|
||||
elif path == "/auth/recover":
|
||||
r.update(Token="tok-456", OnlineId=body.get("Name"), RecoveryCode="RC-43")
|
||||
elif path == "/mp/matching2/create_room":
|
||||
r.update(RoomId=777, SessionId="sess-A", MemberId=1, OwnerMemberId=1, MaxMembers=5, Members=[])
|
||||
elif path == "/mp/matching2/join_room":
|
||||
r.update(SessionId="sess-B", MemberId=2, OwnerMemberId=1, MaxMembers=5, Members=[
|
||||
{"MemberId": 1, "OnlineId": HOST_NPID, "LocalAddr": "10.0.0.9", "LocalPort": 5551,
|
||||
"Addr": "9.9.9.8", "Port": 5552, "MappedAddr": "9.9.9.8", "MappedPort": 5552}])
|
||||
elif path == "/mp/matching2/heartbeat":
|
||||
r.update(InRoom=1)
|
||||
elif path == "/np/signaling/resolve":
|
||||
if self.headers.get("X-BB-Token") == "tok-expired":
|
||||
self._json({"ResKind": 1, "Message": "bad or expired token"}, 401)
|
||||
return
|
||||
r.update(Addr="9.9.9.9", Port=5555)
|
||||
elif path == "/auth/device/start":
|
||||
st["device_polls"] = 0
|
||||
r.update(DeviceCode="dev-1", UserCode="ABCD-EFGH", VerificationUri="http://example.test/link",
|
||||
Interval=1, ExpiresIn=60, Device=body.get("DeviceName"))
|
||||
elif path == "/auth/device/poll":
|
||||
st["device_polls"] = st.get("device_polls", 0) + 1
|
||||
if body.get("DeviceCode") == "dev-1" and st["device_polls"] >= 2:
|
||||
r.update(Status="approved", Token="tok-discord", OnlineId="discorduser")
|
||||
else:
|
||||
r.update(Status="pending")
|
||||
elif path == "/auth/web_code":
|
||||
if body.get("Token") != "tok-discord":
|
||||
self._json({"ResKind": 1, "Message": "unknown token"})
|
||||
return
|
||||
r.update(Code="WXYZ-1234")
|
||||
elif path == "/np/events/poll":
|
||||
r.update(NextCursor=body.get("Cursor", 0) + 1, HasEvent=bool(emit))
|
||||
if emit:
|
||||
r.update(event or {"Name": "room_member_joined", "MemberId": 2, "OnlineId": HOST_NPID,
|
||||
"RoomId": 777, "LocalAddr": "10.0.0.9", "LocalPort": 5551,
|
||||
"Addr": "9.9.9.8", "Port": 5552, "MappedAddr": "9.9.9.8",
|
||||
"MappedPort": 5552})
|
||||
elif path == "/summon_messenger/create":
|
||||
r.update(MessageId="SummonDataCreateResponse")
|
||||
st["summon_token"] = self.headers.get("X-BB-Token", "")
|
||||
elif path == "/gs/echo":
|
||||
r.update(Token=self.headers.get("X-BB-Token", ""), UA=self.headers.get("User-Agent", ""), Body=body)
|
||||
elif path not in ("/mp/matching2/context_start", "/mp/matching2/leave_room", "/auth/logout",
|
||||
"/mp/matching2/signaling_update", "/np/events/ack"):
|
||||
self._json({"ResKind": 1, "Message": "unknown path"}, 404)
|
||||
return
|
||||
self._json(r)
|
||||
|
||||
|
||||
class QuietServer(ThreadingHTTPServer):
|
||||
def handle_error(self, request, client_address):
|
||||
pass # a bridge we terminate drops its keep-alive connections; that is expected
|
||||
|
||||
|
||||
def fake_bbhost(port):
|
||||
srv = QuietServer((HOST, port), FakeBBHost)
|
||||
srv.state = {"lock": threading.Lock(), "calls": [], "emit_event": False, "event": None}
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
return srv
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- bridge process
|
||||
|
||||
class ExeBridge:
|
||||
def __init__(self, cfg):
|
||||
self.dir = tempfile.mkdtemp(prefix="bbbridge-e2e-")
|
||||
self.cfg = dict(cfg, state_file=os.path.join(self.dir, "state.json"), debug_endpoints=True)
|
||||
path = os.path.join(self.dir, "config.json")
|
||||
with open(path, "w") as fh:
|
||||
json.dump(self.cfg, fh)
|
||||
self.log = os.path.join(self.dir, "bridge.log")
|
||||
self.proc = subprocess.Popen([EXE, "--config", path, "--log", self.log],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
urllib.request.urlopen("http://%s:%d/status" % (HOST, self.cfg["webapi_port"]), timeout=1).close()
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError("bridge did not start; log: %s" % self.log)
|
||||
|
||||
def _url(self, path):
|
||||
return "http://%s:%d%s" % (HOST, self.cfg["webapi_port"], path)
|
||||
|
||||
def debug(self):
|
||||
with urllib.request.urlopen(self._url("/debug/state"), timeout=TIMEOUT) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
def post(self, path, obj):
|
||||
req = urllib.request.Request(self._url(path), data=json.dumps(obj).encode(), method="POST",
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
def peer(self, online_id):
|
||||
return next((p for p in self.debug()["peers"] if p["online_id"] == online_id), None)
|
||||
|
||||
def stop(self):
|
||||
self.proc.terminate()
|
||||
self.proc.wait(10)
|
||||
|
||||
|
||||
class ShadNetClient:
|
||||
def __init__(self, port):
|
||||
self.sock = socket.create_connection((HOST, port), timeout=TIMEOUT)
|
||||
self.next_id = 1
|
||||
self.queue = []
|
||||
ptype, _, _, payload = self._read()
|
||||
assert ptype == sp.PACKET_SERVER_INFO and struct.unpack("<I", payload)[0] == sp.PROTOCOL_VERSION
|
||||
|
||||
def _exact(self, n):
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
c = self.sock.recv(n - len(buf))
|
||||
if not c:
|
||||
raise ConnectionError("closed")
|
||||
buf += c
|
||||
return buf
|
||||
|
||||
def _read(self):
|
||||
ptype, cmd, size, pid = sp.parse_header(self._exact(sp.HEADER_SIZE))
|
||||
return ptype, cmd, pid, self._exact(size - sp.HEADER_SIZE) if size > sp.HEADER_SIZE else b""
|
||||
|
||||
def request(self, command, msg=None):
|
||||
payload = sp.proto_blob(msg) if msg is not None else b""
|
||||
self.sock.sendall(sp.build_packet(sp.PACKET_REQUEST, command, self.next_id, payload))
|
||||
self.next_id += 1
|
||||
deadline = time.time() + TIMEOUT * 2
|
||||
while time.time() < deadline:
|
||||
ptype, cmd, _, raw = self._read()
|
||||
if ptype == sp.PACKET_REPLY and cmd == command:
|
||||
return (raw[0] if raw else 255), sp.parse_proto_blob(raw[1:])
|
||||
self.queue.append((ptype, cmd, raw))
|
||||
raise TimeoutError(command)
|
||||
|
||||
def notification(self, kind, timeout=3.0):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
for i, (ptype, cmd, raw) in enumerate(self.queue):
|
||||
if ptype == sp.PACKET_NOTIFICATION and cmd == kind:
|
||||
return self.queue.pop(i)[2]
|
||||
try:
|
||||
self.sock.settimeout(0.5)
|
||||
ptype, cmd, _, raw = self._read()
|
||||
except (socket.timeout, TimeoutError):
|
||||
continue
|
||||
finally:
|
||||
self.sock.settimeout(TIMEOUT)
|
||||
self.queue.append((ptype, cmd, raw))
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
self.sock.close()
|
||||
|
||||
|
||||
def stun_ping(npid, udp_port):
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.settimeout(TIMEOUT)
|
||||
s.sendto(b"\x01" + npid.encode()[:16].ljust(16, b"\0") + socket.inet_aton(HOST), (HOST, udp_port))
|
||||
data, _ = s.recvfrom(64)
|
||||
port = s.getsockname()[1]
|
||||
s.close()
|
||||
return socket.inet_ntoa(data[:4]), struct.unpack(">H", data[4:6])[0], port
|
||||
|
||||
|
||||
def get_json(url):
|
||||
with urllib.request.urlopen(url, timeout=TIMEOUT) as r:
|
||||
return r.status, json.loads(r.read())
|
||||
|
||||
|
||||
def post_json(url, obj, headers=None):
|
||||
req = urllib.request.Request(url, data=json.dumps(obj).encode(), method="POST",
|
||||
headers=dict({"Content-Type": "application/json"}, **(headers or {})))
|
||||
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- A: server path
|
||||
|
||||
def part_a():
|
||||
print("-- A: server path (gateway off)")
|
||||
ports = {"tcp": 42313, "udp": 42314, "webapi": 42315, "bbhost": 42671, "gs": 42316}
|
||||
fake = fake_bbhost(ports["bbhost"])
|
||||
up = "http://%s:%d" % (HOST, ports["bbhost"])
|
||||
br = ExeBridge({"bbhost_server": up, "community_server": up, "bind_host": HOST,
|
||||
"tcp_port": ports["tcp"], "udp_port": ports["udp"], "webapi_port": ports["webapi"],
|
||||
"p2p_gateway": False, "local_addr": HOST, "poll_interval": 0.05,
|
||||
"game_proxy_routes": {str(ports["gs"]): up}, "create_accounts": True})
|
||||
c = None
|
||||
try:
|
||||
c = ShadNetClient(ports["tcp"])
|
||||
err, blob = c.request(sp.Command.Login, proto3.encode_message(sp.LOGIN_REQUEST, {1: NPID, 4: "CUSA03173"}))
|
||||
reply = proto3.decode_message(sp.LOGIN_REPLY, blob) if blob else {}
|
||||
check("login accepted with the per-player account id",
|
||||
err == 0 and reply.get("user_id") == account_id_for(NPID), (err, reply))
|
||||
check("bbhost account created", "/auth/account/create" in fake.state["calls"])
|
||||
err, blob = c.request(sp.Command.GetServerFeatures)
|
||||
check("matching2 enabled", err == 0 and proto3.decode_message(sp.SERVER_FEATURES_REPLY, blob).get("matching2_enabled") is True)
|
||||
err, _ = c.request(sp.Command.SetClientVersion)
|
||||
check("client version accepted", err == 0, err)
|
||||
err, blob = c.request(sp.Command.GetToken)
|
||||
tok = proto3.decode_message(sp.GET_TOKEN_REPLY, blob)
|
||||
check("token relayed", err == 0 and tok.get("token") == "tok-123" and tok.get("npid") == NPID, tok)
|
||||
|
||||
ip, port, local = stun_ping(NPID, ports["udp"])
|
||||
check("stun reply", ip == HOST and port == local, (ip, port, local))
|
||||
time.sleep(0.2)
|
||||
check("stun registry", br.debug()["registry"].get(NPID) == [HOST, local], br.debug()["registry"])
|
||||
# bbport sends its ping inside a P2P signaling frame (FF FF FF FF first): registered, no echo.
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.settimeout(0.5)
|
||||
s.sendto(b"\xff\xff\xff\xff\x01" + b"framed".ljust(16, b"\0") + socket.inet_aton(HOST), (HOST, ports["udp"]))
|
||||
framed_port = s.getsockname()[1]
|
||||
try:
|
||||
s.recvfrom(64)
|
||||
echoed = True
|
||||
except socket.timeout:
|
||||
echoed = False
|
||||
s.close()
|
||||
check("framed stun ping registered without an echo",
|
||||
br.debug()["registry"].get("framed") == [HOST, framed_port] and not echoed, br.debug()["registry"])
|
||||
|
||||
err, _ = c.request(sp.Command.ContextStart, proto3.encode_message(sp.CONTEXT_START_REQUEST, {1: 7}))
|
||||
check("context_start", err == 0 and "/mp/matching2/context_start" in fake.state["calls"], err)
|
||||
|
||||
err, blob = c.request(sp.Command.CreateRoom, proto3.encode_message(sp.CREATE_ROOM_REQUEST, {1: 1, 2: 5}))
|
||||
reply = proto3.decode_message(sp.CREATE_ROOM_REPLY, blob) if blob else {}
|
||||
check("create_room accepted", err == 0 and reply.get("room_id") == 777 and reply.get("member_id") == 1
|
||||
and reply.get("max_slots") == 5, (err, reply))
|
||||
details = reply.get("details") or {}
|
||||
check("create_room details", details.get("me_member_id") == 1 and details.get("owner_member_id") == 1
|
||||
and (details.get("room_data") or {}).get("room_id") == 777, details)
|
||||
me = [m for m in details.get("members") or [] if m.get("member_id") == 1]
|
||||
check("create_room lists ourselves (port needs members.me/owner)",
|
||||
len(me) == 1 and me[0].get("npid") == NPID and me[0].get("is_owner") is True, details.get("members"))
|
||||
|
||||
with fake.state["lock"]:
|
||||
fake.state["emit_event"] = True
|
||||
raw = c.notification(sp.Notify.RoomEvent)
|
||||
with fake.state["lock"]:
|
||||
fake.state["emit_event"] = False
|
||||
ev = proto3.decode_message(sp.NOTIFY_ROOM_EVENT, sp.parse_proto_blob(raw)) if raw else {}
|
||||
check("room_member_joined notification", ev.get("event") == sp.RoomEvent.MemberJoined
|
||||
and ev.get("room_id") == 777 and (ev.get("member") or {}).get("npid") == HOST_NPID
|
||||
and (ev.get("member") or {}).get("member_id") == 2, ev)
|
||||
|
||||
err, blob = c.request(sp.Command.RequestSignalingInfos,
|
||||
proto3.encode_message(sp.REQUEST_SIGNALING_INFOS_REQUEST, {1: HOST_NPID}))
|
||||
sig = proto3.decode_message(sp.REQUEST_SIGNALING_INFOS_REPLY, blob) if blob else {}
|
||||
check("signaling resolve", err == 0 and sig.get("target_ip") == "9.9.9.9" and sig.get("target_port") == 5555, sig)
|
||||
|
||||
webapi = "http://%s:%d" % (HOST, ports["webapi"])
|
||||
check("webapi /status", get_json(webapi + "/status")[1].get("ok") is True)
|
||||
ok = True
|
||||
for name in ("friendList", "blockList"):
|
||||
status, body = get_json("%s/v1/users/%s/%s?fields=npId&limit=50" % (webapi, NPID, name))
|
||||
ok = ok and status == 200 and body.get(name) == [] and body.get("totalResults") == 0
|
||||
check("webapi friendList/blockList answer empty lists", ok)
|
||||
ad = {"SessionId": "sess-A", "UserId": 1, "AreaId": 3, "SummonData": "QUJD", "MatchingLevel": 44}
|
||||
r = post_json(webapi + "/summon_messenger/create", ad)
|
||||
check("summon create proxied", r.get("ResKind") == 0 and r.get("MessageId") == "SummonDataCreateResponse", r)
|
||||
check("summon proxy carries the account token", fake.state.get("summon_token") == "tok-123",
|
||||
fake.state.get("summon_token"))
|
||||
echo = post_json("http://%s:%d/gs/echo" % (HOST, ports["gs"]), {"x": 1}, {"User-Agent": "BloodborneGame"})
|
||||
check("game proxy forwards with token and own UA", echo.get("Token") == "tok-123" and echo.get("Body") == {"x": 1}
|
||||
and echo.get("UA", "").startswith("BloodborneGame bbbridge/"), echo)
|
||||
check("advertisement captured", (br.debug().get("last_advertisement") or {}).get("AreaId") == 3)
|
||||
|
||||
err, blob = c.request(sp.Command.LeaveRoom, proto3.encode_message(sp.LEAVE_ROOM_REQUEST, {1: 777, 2: 2}))
|
||||
check("leave_room accepted", err == 0 and proto3.decode_message(sp.LEAVE_ROOM_REPLY, blob).get("room_id") == 777
|
||||
and "/mp/matching2/leave_room" in fake.state["calls"])
|
||||
err, _ = c.request(sp.Command.Terminate)
|
||||
closed = False
|
||||
try:
|
||||
c.sock.settimeout(2)
|
||||
closed = not c.sock.recv(16)
|
||||
except OSError:
|
||||
closed = True
|
||||
check("terminate closes the connection", err == 0 and closed)
|
||||
|
||||
c2 = ShadNetClient(ports["tcp"])
|
||||
err, _ = c2.request(sp.Command.Login, proto3.encode_message(sp.LOGIN_REQUEST, {1: NPID}))
|
||||
_, blob = c2.request(sp.Command.GetToken)
|
||||
c2.close()
|
||||
check("second login uses the stored token, no recover",
|
||||
err == 0 and fake.state["calls"].count("/auth/recover") == 0
|
||||
and proto3.decode_message(sp.GET_TOKEN_REPLY, blob).get("token") == "tok-123")
|
||||
finally:
|
||||
if c:
|
||||
c.close()
|
||||
br.stop()
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- B: gateway
|
||||
|
||||
class FakeStun(threading.Thread):
|
||||
def __init__(self):
|
||||
super().__init__(daemon=True)
|
||||
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self.sock.bind((HOST, 0))
|
||||
self.sock.settimeout(0.2)
|
||||
self.port = self.sock.getsockname()[1]
|
||||
self.relay_port = 41000
|
||||
self.token = b"RELAYTOK"
|
||||
self.requests, self.relayed = [], []
|
||||
self.stop = False
|
||||
|
||||
def run(self):
|
||||
while not self.stop:
|
||||
try:
|
||||
data, src = self.sock.recvfrom(65536)
|
||||
except (socket.timeout, OSError):
|
||||
continue
|
||||
if data[:2] == b"\xfbR":
|
||||
self.relayed.append(data)
|
||||
continue
|
||||
if len(data) < 20 or struct.unpack(">H", data[:2])[0] != 1:
|
||||
continue
|
||||
self.requests.append(data)
|
||||
attrs = struct.pack(">HHBBH4s", 0x0001, 8, 0, 1, 5678, socket.inet_aton("1.2.3.4"))
|
||||
if self.relay_port and len(data) >= 36 and data[20:22] == b"\x81\x00":
|
||||
v = b"bbr1" + self.token + struct.pack(">H", self.relay_port) + socket.inet_aton(src[0]) + struct.pack(">H", src[1])
|
||||
attrs += struct.pack(">HH", 0x8101, len(v)) + v
|
||||
self.sock.sendto(struct.pack(">HH", 0x0101, len(attrs)) + data[4:20] + attrs, src)
|
||||
|
||||
|
||||
def udp(timeout=2.0):
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.bind((HOST, 0))
|
||||
s.settimeout(timeout)
|
||||
return s
|
||||
|
||||
|
||||
def recv_until(sock, pred, timeout=2.0):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
sock.settimeout(max(0.05, deadline - time.time()))
|
||||
try:
|
||||
data, src = sock.recvfrom(65536)
|
||||
except socket.timeout:
|
||||
return None
|
||||
if pred(data):
|
||||
return data, src
|
||||
return None
|
||||
|
||||
|
||||
def handshake(kind, frm, to, from_conn=7, nonce=0):
|
||||
return b"SHAD" + bytes([0x07, kind]) + frm.encode().ljust(16, b"\0") + to.encode().ljust(16, b"\0") + \
|
||||
struct.pack("<IIIHHQ", from_conn, 0, 0, 0, 0, nonce)
|
||||
|
||||
|
||||
def m2(kind, room, frm_member, to_member, frm_id, nonce=0):
|
||||
return b"SHAD" + bytes([0x21, kind]) + struct.pack("<QHH", room, frm_member, to_member) + \
|
||||
frm_id.encode().ljust(16, b"\0") + struct.pack("<IHHQ", 0, 0, 0, nonce)
|
||||
|
||||
|
||||
def part_b():
|
||||
print("-- B: gateway over real sockets")
|
||||
stun = FakeStun()
|
||||
stun.start()
|
||||
ports = {"tcp": 43313, "udp": 43314, "webapi": 43315}
|
||||
br = ExeBridge({"bbhost_server": "http://%s:1" % HOST, "bind_host": HOST, "tcp_port": ports["tcp"],
|
||||
"udp_port": ports["udp"], "webapi_port": ports["webapi"], "local_addr": HOST,
|
||||
"stun_server": "%s:%d" % (HOST, stun.port), "stun_refresh": 0.3, "game_proxy_routes": {}})
|
||||
port_sock, peer_sock = udp(), udp()
|
||||
try:
|
||||
deadline = time.time() + 4
|
||||
while time.time() < deadline and not (br.debug().get("mapped") and br.debug().get("relay_port")):
|
||||
time.sleep(0.1)
|
||||
st = br.debug()
|
||||
check("STUN request carries BBHOST-HELLO", bool(stun.requests) and stun.requests[0][20:24] == b"\x81\x00\x00\x0c")
|
||||
check("STUN mapping learned", st.get("mapped") == ["1.2.3.4", 5678], st.get("mapped"))
|
||||
check("relay turned on", st.get("relay_port") == 41000, st.get("relay_port"))
|
||||
|
||||
stun.relay_port = 0 # direct part: the next refresh turns the relay off
|
||||
deadline = time.time() + 3
|
||||
while time.time() < deadline and br.debug().get("relay_port"):
|
||||
time.sleep(0.1)
|
||||
inner = tuple(br.post("/debug/add_peer", {
|
||||
"member_id": 2, "online_id": "hunter", "endpoint": list(peer_sock.getsockname()),
|
||||
"port_online_id": "porthost", "port_member_id": 1, "room_id": 777,
|
||||
"port_endpoint": list(port_sock.getsockname())})["inner"])
|
||||
check("inner endpoint on loopback", inner[0] == HOST and inner[1] > 0, inner)
|
||||
opening = recv_until(port_sock, lambda d: d[4:9] == b"SHAD\x10")
|
||||
check("peer opens with ActivationRequest to the port",
|
||||
opening is not None and opening[0][9] == 1 and opening[1] == inner, opening)
|
||||
|
||||
port_sock.sendto(struct.pack(">HH", 40, 30) + b"hello-peer", inner)
|
||||
got = recv_until(peer_sock, lambda d: d[:1] == b"\xff")
|
||||
check("port game data reaches the peer in bbhost framing",
|
||||
got is not None and got[0] == b"\xff\xc3\x28\x1ehello-peer", got)
|
||||
outer_port = got[1][1] if got else 0
|
||||
peer_sock.sendto(b"\xff\xc3\x1e\x28hello-port", (HOST, outer_port))
|
||||
got = recv_until(port_sock, lambda d: d.endswith(b"hello-port"))
|
||||
check("peer game data reaches the port in port framing",
|
||||
got is not None and got[0] == struct.pack(">HH", 30, 40) + b"hello-port" and got[1] == inner, got)
|
||||
peer_sock.sendto(b"\xfebbhp\0\0\0", (HOST, outer_port))
|
||||
got = recv_until(port_sock, lambda d: d[:4] != b"\xff\xff\xff\xff", timeout=0.4)
|
||||
check("hole-punch probe not delivered to the port", got is None, got)
|
||||
|
||||
sig = struct.pack(">HH", 0xFFFF, 0xFFFF)
|
||||
port_sock.sendto(sig + handshake(1, "porthost", "hunter"), inner)
|
||||
check("port's handshake Offer answered with Accept",
|
||||
recv_until(port_sock, lambda d: d[4:10] == b"SHAD\x07\x02") is not None)
|
||||
port_sock.sendto(sig + m2(3, 777, 1, 2, "porthost", nonce=42), inner)
|
||||
got = recv_until(port_sock, lambda d: d[4:10] == b"SHAD\x21\x04")
|
||||
check("port's matching2 Check answered with CheckAck",
|
||||
got is not None and struct.unpack("<Q", got[0][4 + 42:4 + 50])[0] == 42)
|
||||
|
||||
stun.relay_port = 41000
|
||||
deadline = time.time() + 3
|
||||
while time.time() < deadline and not br.debug().get("relay_port"):
|
||||
time.sleep(0.1)
|
||||
inner3 = tuple(br.post("/debug/add_peer", {
|
||||
"member_id": 3, "online_id": "relayed", "endpoint": [HOST, 41001], "port_online_id": "porthost",
|
||||
"port_member_id": 1, "room_id": 777, "port_endpoint": list(port_sock.getsockname())})["inner"])
|
||||
port_sock.sendto(struct.pack(">HH", 40, 30) + b"via-relay", inner3)
|
||||
deadline = time.time() + 2
|
||||
while time.time() < deadline and not stun.relayed:
|
||||
time.sleep(0.05)
|
||||
expect = b"\xfbR" + stun.token + struct.pack(">H", 41001) + b"\xff\xc3\x28\x1evia-relay"
|
||||
check("datagram for a relayed peer goes framed to the STUN port", stun.relayed[:1] == [expect], stun.relayed[:1])
|
||||
stun.sock.sendto(b"\xfbr" + struct.pack(">H", 41001) + b"\xff\xc3\x1e\x28back", (HOST, outer_port))
|
||||
got = recv_until(port_sock, lambda d: d.endswith(b"back"))
|
||||
check("relay delivery reaches the port from the right member", got is not None and got[1] == inner3, got)
|
||||
|
||||
br.post("/debug/remove_peer", {"member_id": 2})
|
||||
check("removed peer forgotten", br.peer("hunter") is None)
|
||||
finally:
|
||||
stun.stop = True
|
||||
br.stop()
|
||||
port_sock.close()
|
||||
peer_sock.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- C: bridge + gateway
|
||||
|
||||
def part_c():
|
||||
print("-- C: bridge flows with the gateway on")
|
||||
ports = {"tcp": 44313, "udp": 44314, "webapi": 44315, "bbhost": 44671}
|
||||
fake = fake_bbhost(ports["bbhost"])
|
||||
up = "http://%s:%d" % (HOST, ports["bbhost"])
|
||||
br = ExeBridge({"bbhost_server": up, "community_server": up, "bind_host": HOST, "tcp_port": ports["tcp"],
|
||||
"udp_port": ports["udp"], "webapi_port": ports["webapi"], "local_addr": HOST,
|
||||
"poll_interval": 0.05, "stun_server": "off", "game_proxy_routes": {}, "create_accounts": True})
|
||||
c = None
|
||||
try:
|
||||
c = ShadNetClient(ports["tcp"])
|
||||
c.request(sp.Command.Login, proto3.encode_message(sp.LOGIN_REQUEST, {1: NPID}))
|
||||
stun_ping(NPID, ports["udp"])
|
||||
c.request(sp.Command.ContextStart, proto3.encode_message(sp.CONTEXT_START_REQUEST, {1: 7}))
|
||||
with fake.state["lock"]:
|
||||
fake.state["event"] = {"Name": "guest_invite", "RoomId": 777, "HostOnlineId": HOST_NPID,
|
||||
"HostAddr": "9.9.9.8", "HostPort": 5552, "HostLocalAddr": "10.0.0.9",
|
||||
"HostLocalPort": 5551, "HostMappedAddr": "9.9.9.8", "HostMappedPort": 5552,
|
||||
"HostArea": 3, "HostLevel": 26}
|
||||
fake.state["emit_event"] = True
|
||||
deadline = time.time() + 3
|
||||
while time.time() < deadline and br.peer(HOST_NPID) is None:
|
||||
time.sleep(0.05)
|
||||
with fake.state["lock"]:
|
||||
fake.state["emit_event"] = False
|
||||
fake.state["event"] = None
|
||||
seeded = br.peer(HOST_NPID)
|
||||
check("guest_invite seeds the host in the gateway", seeded is not None)
|
||||
|
||||
err, blob = c.request(sp.Command.JoinRoom, proto3.encode_message(sp.JOIN_ROOM_REQUEST, {1: 777, 2: 1}))
|
||||
reply = proto3.decode_message(sp.JOIN_ROOM_REPLY, blob) if blob else {}
|
||||
host = next((m for m in (reply.get("details") or {}).get("members") or [] if m.get("npid") == HOST_NPID), {})
|
||||
link = br.peer(HOST_NPID)
|
||||
check("joining the invited room keeps the host's inner endpoint",
|
||||
seeded is not None and link is not None and link["inner"] == seeded["inner"], (seeded, link))
|
||||
check("the join fills in the port's member id for the handshakes",
|
||||
link is not None and link["port_member_id"] == 2 and link["room_id"] == 777, link)
|
||||
check("joined room member points at the gateway",
|
||||
err == 0 and link is not None and host.get("addr") == HOST and host.get("port") == link["inner"][1], host)
|
||||
err, blob = c.request(sp.Command.RequestSignalingInfos,
|
||||
proto3.encode_message(sp.REQUEST_SIGNALING_INFOS_REQUEST, {1: HOST_NPID}))
|
||||
sig = proto3.decode_message(sp.REQUEST_SIGNALING_INFOS_REPLY, blob) if blob else {}
|
||||
check("signaling infos point at the gateway",
|
||||
err == 0 and link is not None and sig.get("target_ip") == HOST and sig.get("target_port") == link["inner"][1], sig)
|
||||
|
||||
err, blob = c.request(sp.Command.RequestSignalingInfos,
|
||||
proto3.encode_message(sp.REQUEST_SIGNALING_INFOS_REQUEST, {1: "summonedguest"}))
|
||||
sig2 = proto3.decode_message(sp.REQUEST_SIGNALING_INFOS_REPLY, blob) if blob else {}
|
||||
early = br.peer("summonedguest")
|
||||
check("signaling infos for a non-member resolve through the server",
|
||||
err == 0 and early is not None and sig2.get("target_port") == early["inner"][1], (sig2, early))
|
||||
with fake.state["lock"]:
|
||||
fake.state["event"] = {"Name": "room_member_joined", "MemberId": 3, "RoomId": 777,
|
||||
"OnlineId": "summonedguest", "Addr": "9.9.9.9", "Port": 5555}
|
||||
fake.state["emit_event"] = True
|
||||
deadline = time.time() + 3
|
||||
while time.time() < deadline and (br.peer("summonedguest") or {}).get("member_id") != 3:
|
||||
time.sleep(0.05)
|
||||
with fake.state["lock"]:
|
||||
fake.state["emit_event"] = False
|
||||
fake.state["event"] = None
|
||||
joined = br.peer("summonedguest")
|
||||
check("the guest joining keeps the inner endpoint the port already uses",
|
||||
joined is not None and early is not None and joined["member_id"] == 3 and joined["inner"] == early["inner"]
|
||||
and joined["peer_member_id"] == 3 and all(p["member_id"] > 0 for p in br.debug()["peers"]), joined)
|
||||
|
||||
time.sleep(5.6)
|
||||
check("heartbeat inside the server's 15 s window", fake.state["calls"].count("/mp/matching2/heartbeat") >= 1)
|
||||
c.request(sp.Command.LeaveRoom, proto3.encode_message(sp.LEAVE_ROOM_REQUEST, {1: 777}))
|
||||
check("leaving the room drops the peers", br.debug()["peers"] == [], br.debug()["peers"])
|
||||
finally:
|
||||
if c:
|
||||
c.close()
|
||||
br.stop()
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- D: Discord sign-in
|
||||
|
||||
def part_d():
|
||||
print("-- D: sign in with Discord, website code, expired token")
|
||||
port = 45671
|
||||
fake = fake_bbhost(port)
|
||||
up = "http://%s:%d" % (HOST, port)
|
||||
d = tempfile.mkdtemp(prefix="bbbridge-e2e-")
|
||||
state = os.path.join(d, "state.json")
|
||||
cfg_path = os.path.join(d, "config.json")
|
||||
with open(cfg_path, "w") as fh:
|
||||
json.dump({"auth_server": up, "bbhost_server": up, "state_file": state}, fh)
|
||||
try:
|
||||
r = subprocess.run([EXE, "--config", cfg_path, "--link-discord"], capture_output=True, text=True, timeout=30)
|
||||
check("device code shown to the player", "ABCD-EFGH" in r.stdout and "http://example.test/link" in r.stdout,
|
||||
r.stdout)
|
||||
check("approval signs in", r.returncode == 0 and "signed in as discorduser" in r.stdout, r.stdout + r.stderr)
|
||||
with open(state) as fh:
|
||||
acc = json.load(fh)["accounts"].get("discorduser", {})
|
||||
check("Discord account stored under its name",
|
||||
acc.get("token") == "tok-discord" and acc.get("linked") == "discord" and not acc.get("recovery_code"), acc)
|
||||
r = subprocess.run([EXE, "--config", cfg_path, "--web-code", "discorduser"], capture_output=True, text=True,
|
||||
timeout=30)
|
||||
check("website sign-in code", r.returncode == 0 and r.stdout.strip() == "WXYZ-1234", r.stdout + r.stderr)
|
||||
|
||||
def cli(*args):
|
||||
return subprocess.run([EXE, "--config", cfg_path, *args], capture_output=True, text=True, timeout=30)
|
||||
|
||||
def accounts():
|
||||
with open(state) as fh:
|
||||
return json.load(fh)["accounts"]
|
||||
|
||||
r = cli("--create", "pcplayer")
|
||||
check("create account on this PC", r.returncode == 0 and accounts().get("pcplayer", {}).get("recovery_code") == "RC-42"
|
||||
and accounts()["pcplayer"].get("token") == "tok-123", r.stdout + str(accounts().get("pcplayer")))
|
||||
r = cli("--recover", "pcplayer", "RC-42")
|
||||
check("recover account keeps the server's new code", r.returncode == 0
|
||||
and accounts()["pcplayer"].get("recovery_code") == "RC-43" and accounts()["pcplayer"].get("token") == "tok-456",
|
||||
accounts().get("pcplayer"))
|
||||
r = cli("--sign-out", "pcplayer")
|
||||
check("sign out ends the token, keeps the recovery code", r.returncode == 0 and "/auth/logout" in fake.state["calls"]
|
||||
and "token" not in accounts()["pcplayer"] and accounts()["pcplayer"].get("recovery_code") == "RC-43",
|
||||
accounts().get("pcplayer"))
|
||||
r = cli("--sign-out", "discorduser")
|
||||
check("signing out of a Discord account forgets it here", r.returncode == 0 and "discorduser" not in accounts())
|
||||
acc = dict(acc) # the bridge part below uses the Discord account as linked earlier
|
||||
creates = fake.state["calls"].count("/auth/account/create") # the bridge must add none below
|
||||
|
||||
br = ExeBridge({"bbhost_server": up, "community_server": up, "auth_server": up, "bind_host": HOST,
|
||||
"tcp_port": 45313, "udp_port": 45314, "webapi_port": 45315, "p2p_gateway": False,
|
||||
"local_addr": HOST, "game_proxy_routes": {}})
|
||||
try:
|
||||
with open(br.cfg["state_file"], "w") as fh:
|
||||
json.dump({"accounts": {"discorduser": acc, "expired": {
|
||||
"bbhost_name": "expired", "token": "tok-expired", "online_id": "expired", "linked": "discord"}}}, fh)
|
||||
c = ShadNetClient(45313)
|
||||
err, _ = c.request(sp.Command.Login, proto3.encode_message(sp.LOGIN_REQUEST, {1: "discorduser"}))
|
||||
_, blob = c.request(sp.Command.GetToken)
|
||||
c.close()
|
||||
check("the bridge plays as the Discord account",
|
||||
err == 0 and proto3.decode_message(sp.GET_TOKEN_REPLY, blob).get("token") == "tok-discord"
|
||||
and fake.state["calls"].count("/auth/account/create") == creates)
|
||||
c = ShadNetClient(45313)
|
||||
err, _ = c.request(sp.Command.Login, proto3.encode_message(sp.LOGIN_REQUEST, {1: "expired"}))
|
||||
c.close()
|
||||
with open(br.log) as fh:
|
||||
said = "has expired" in fh.read()
|
||||
check("an expired Discord sign-in is refused, not replaced by a new account",
|
||||
err == sp.Error.LoginError and said and fake.state["calls"].count("/auth/account/create") == creates, err)
|
||||
c = ShadNetClient(45313)
|
||||
err, _ = c.request(sp.Command.Login, proto3.encode_message(sp.LOGIN_REQUEST, {1: "stranger"}))
|
||||
c.close()
|
||||
with open(br.log) as fh:
|
||||
said = "use 'Sign in with Discord' in bbbridge first" in fh.read()
|
||||
check("no account on this PC: sign in with Discord first, nothing created",
|
||||
err == sp.Error.LoginError and said and fake.state["calls"].count("/auth/account/create") == creates, err)
|
||||
finally:
|
||||
br.stop()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- E: bbport setup
|
||||
|
||||
def part_e():
|
||||
print("-- E: routing a fresh bbport (sandboxed APPDATA)")
|
||||
tmp = tempfile.mkdtemp(prefix="bbbridge-e2e-")
|
||||
appdata, bbport = os.path.join(tmp, "AppData"), os.path.join(tmp, "bloodborne")
|
||||
user = os.path.join(bbport, "user")
|
||||
os.makedirs(os.path.join(appdata, "bbport-launcher"))
|
||||
os.makedirs(user)
|
||||
open(os.path.join(bbport, "Bloodborne.exe"), "wb").close()
|
||||
mine = {"https://example.test:1": "http://127.0.0.1:1"}
|
||||
with open(os.path.join(user, "host_overrides.custom.json"), "w") as fh:
|
||||
json.dump(mine, fh)
|
||||
sfile = os.path.join(appdata, "bbport-launcher", "settings.json")
|
||||
fresh = {"ui_language": "", "game_dir": os.path.join(bbport, "CUSA03173"), "user_dir": "", "online": False,
|
||||
"online_server": "srv.shadps4.net:31313", "online_webapi": "", "online_npid": "", "online_password": "",
|
||||
"online_upnp": True, "extra_env": "BB_GPU_LOG=info SHADPS4_P2P_PORT=1"}
|
||||
with open(sfile, "w") as fh:
|
||||
json.dump(fresh, fh)
|
||||
env = dict(os.environ, APPDATA=appdata)
|
||||
|
||||
def route(on):
|
||||
return subprocess.run([EXE, "--route", "on" if on else "off", "--bbport", bbport], env=env,
|
||||
capture_output=True, text=True, timeout=30)
|
||||
|
||||
def settings():
|
||||
with open(sfile) as fh:
|
||||
return json.load(fh)
|
||||
|
||||
r = route(True)
|
||||
s = settings()
|
||||
check("route on: bbport points at the bridge", r.returncode == 0 and "routed=1" in r.stdout and s["online"] is True
|
||||
and s["online_server"] == "127.0.0.1:31313" and s["online_webapi"] == "http://127.0.0.1:31315", r.stdout + str(s))
|
||||
check("route on: an empty password gets a placeholder (bbport skips online without one)",
|
||||
s["online_password"] == "bbbridge", s["online_password"])
|
||||
check("route on: extra variables merged, P2P port pinned once",
|
||||
s["extra_env"].split() == ["BB_GPU_LOG=info", "SHADPS4_P2P_PORT=3658"], s["extra_env"])
|
||||
check("route on: npid untouched, key order kept", s["online_npid"] == "" and list(s)[:4] == list(fresh)[:4], list(s))
|
||||
ov = json.load(open(os.path.join(user, "host_overrides.custom.json")))
|
||||
check("route on: overrides written, the player's own backed up",
|
||||
"_bbbridge" in ov and json.load(open(os.path.join(user, "host_overrides.custom.json.before-bbbridge"))) == mine)
|
||||
r = route(False)
|
||||
s = settings()
|
||||
check("route off: back to shadNet, password and variables restored",
|
||||
r.returncode == 0 and s["online_server"] == "srv.shadps4.net:31313" and s["online_webapi"] == ""
|
||||
and s["online_password"] == "" and s["extra_env"] == "BB_GPU_LOG=info", s)
|
||||
check("route off: the player's own overrides restored",
|
||||
json.load(open(os.path.join(user, "host_overrides.custom.json"))) == mine)
|
||||
s["online_password"] = "secret"
|
||||
with open(sfile, "w") as fh:
|
||||
json.dump(s, fh)
|
||||
route(True)
|
||||
on_pw = settings()["online_password"]
|
||||
route(False)
|
||||
check("a real shadNet password is left alone", on_pw == "secret" and settings()["online_password"] == "secret",
|
||||
(on_pw, settings()["online_password"]))
|
||||
|
||||
def set_settings(**kv):
|
||||
s = settings()
|
||||
s.update(kv)
|
||||
with open(sfile, "w") as fh:
|
||||
json.dump(s, fh)
|
||||
|
||||
# the Python bbbridge's file is ours too: replaced, then removed - not kept as the player's
|
||||
ovfile = os.path.join(user, "host_overrides.custom.json")
|
||||
with open(ovfile, "w") as fh:
|
||||
json.dump({"_note": "bbbridge: the game's own server traffic goes through the bridge",
|
||||
"https://thehuntersdream.com:20443": "http://127.0.0.1:31316"}, fh)
|
||||
route(True)
|
||||
route(False)
|
||||
check("route off: an old (Python) bbbridge overrides file is removed, not restored",
|
||||
not os.path.exists(ovfile) and not os.path.exists(ovfile + ".before-bbbridge"),
|
||||
os.listdir(user))
|
||||
|
||||
# the app puts the Hunter's Dream account in while routed; off brings the shadNet one back
|
||||
for name, pw in (("hunter_sn", "secret"), ("renamed_sn", "secret2")): # also when changed in between
|
||||
set_settings(online_npid=name, online_password=pw)
|
||||
route(True)
|
||||
set_settings(online_npid="discord_name")
|
||||
route(False)
|
||||
s = settings()
|
||||
check("route off: the shadNet account comes back (%s)" % name,
|
||||
s["online_npid"] == name and s["online_password"] == pw and s["online_server"] == "srv.shadps4.net:31313",
|
||||
(s["online_npid"], s["online_server"]))
|
||||
|
||||
|
||||
def main():
|
||||
print("== bbbridge C++ end-to-end (%s) ==" % os.path.abspath(EXE))
|
||||
part_a()
|
||||
part_b()
|
||||
part_c()
|
||||
part_d()
|
||||
if os.name == "nt": # routing bbport for Windows (setup.cpp); on Linux the bbport launcher does it
|
||||
part_e()
|
||||
print("\n%d passed, %d failed" % (len(PASSED), len(FAILED)))
|
||||
sys.exit(1 if FAILED else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,209 @@
|
||||
// Byte-level unit tests: the same checks as the Python bridge's test_p2p.py
|
||||
// part 1, plus protobuf and the account id (values the Python bridge produced).
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "bridge.hpp"
|
||||
#include "p2p.hpp"
|
||||
#include "proto3.hpp"
|
||||
#ifdef _WIN32
|
||||
#include "setup.hpp"
|
||||
#endif
|
||||
#include "shadnet.hpp"
|
||||
|
||||
using namespace bb;
|
||||
using namespace bb::p2p;
|
||||
|
||||
static int g_pass = 0, g_fail = 0;
|
||||
|
||||
static void check(const char* name, bool ok, const std::string& detail = "") {
|
||||
if (ok) {
|
||||
++g_pass;
|
||||
std::printf(" [PASS] %s\n", name);
|
||||
} else {
|
||||
++g_fail;
|
||||
std::printf(" [FAIL] %s %s\n", name, detail.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
static std::string hex(const std::string& s) {
|
||||
std::string out;
|
||||
for (unsigned char c : s) out += fmt("%02x", c);
|
||||
return out;
|
||||
}
|
||||
|
||||
static std::string be16s(uint16_t v) { return std::string{static_cast<char>(v >> 8), static_cast<char>(v & 0xFF)}; }
|
||||
|
||||
static std::string handshake(uint8_t kind, const std::string& from, const std::string& to, uint32_t from_conn,
|
||||
uint64_t nonce) {
|
||||
std::string p = "SHAD";
|
||||
p.push_back(static_cast<char>(T_HANDSHAKE));
|
||||
p.push_back(static_cast<char>(kind));
|
||||
std::string f = from, t = to;
|
||||
f.resize(16, '\0');
|
||||
t.resize(16, '\0');
|
||||
p += f + t;
|
||||
for (int i = 0; i < 4; ++i) p.push_back(static_cast<char>((from_conn >> (8 * i)) & 0xFF));
|
||||
p += std::string(12, '\0');
|
||||
for (int i = 0; i < 8; ++i) p.push_back(static_cast<char>((nonce >> (8 * i)) & 0xFF));
|
||||
return p;
|
||||
}
|
||||
|
||||
static std::string m2(uint8_t kind, uint64_t room, uint16_t from_member, uint16_t to_member, const std::string& from,
|
||||
uint64_t nonce) {
|
||||
std::string p = "SHAD";
|
||||
p.push_back(static_cast<char>(T_M2));
|
||||
p.push_back(static_cast<char>(kind));
|
||||
for (int i = 0; i < 8; ++i) p.push_back(static_cast<char>((room >> (8 * i)) & 0xFF));
|
||||
p.push_back(static_cast<char>(from_member & 0xFF));
|
||||
p.push_back(static_cast<char>(from_member >> 8));
|
||||
p.push_back(static_cast<char>(to_member & 0xFF));
|
||||
p.push_back(static_cast<char>(to_member >> 8));
|
||||
std::string f = from;
|
||||
f.resize(16, '\0');
|
||||
p += f + std::string(8, '\0');
|
||||
for (int i = 0; i < 8; ++i) p.push_back(static_cast<char>((nonce >> (8 * i)) & 0xFF));
|
||||
return p;
|
||||
}
|
||||
|
||||
static std::string control(uint8_t kind, const std::string& from, const std::string& to) {
|
||||
std::string p = "SHAD";
|
||||
p.push_back(static_cast<char>(T_CONTROL));
|
||||
p.push_back(static_cast<char>(kind));
|
||||
std::string f = from, t = to;
|
||||
f.resize(16, '\0');
|
||||
t.resize(16, '\0');
|
||||
p += f + t + std::string(12, '\0');
|
||||
return p;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
static T le(const std::string& s, size_t at) {
|
||||
uint64_t v = 0;
|
||||
for (size_t i = 0; i < sizeof(T); ++i) v |= static_cast<uint64_t>(static_cast<uint8_t>(s[at + i])) << (8 * i);
|
||||
return static_cast<T>(v);
|
||||
}
|
||||
|
||||
int main() {
|
||||
std::printf("== bbbridge unit ==\n");
|
||||
std::string out;
|
||||
|
||||
// framing
|
||||
check("port->bbhost one-byte vports",
|
||||
port_to_ps4(be16s(40) + be16s(30) + "game", out) == Frame::Game && out == std::string("\xff\xc3\x28\x1e") + "game",
|
||||
hex(out));
|
||||
check("bbhost->port one-byte vports",
|
||||
ps4_to_port(std::string("\xff\xc3\x1e\x28") + "data", out) && out == be16s(30) + be16s(40) + "data", hex(out));
|
||||
std::string two;
|
||||
port_to_ps4(be16s(3000) + be16s(40) + "x", two);
|
||||
check("two-byte vports round trip", ps4_to_port(two, out) && out == be16s(3000) + be16s(40) + "x", hex(two));
|
||||
check("comid flag skipped",
|
||||
ps4_to_port(std::string("\xff\xe3") + "ABCD" + "\x28\x1e" + "z", out) && out == be16s(40) + be16s(30) + "z");
|
||||
check("probe is not game data", !ps4_to_port(kProbe, out));
|
||||
check("port signaling channel detected",
|
||||
port_to_ps4(be16s(0xFFFF) + be16s(0xFFFF) + "SHAD\x05", out) == Frame::Signaling && out == "SHAD\x05");
|
||||
const std::string token = "T0K3N!!!";
|
||||
const std::string wrapped = relay_wrap(token, 41000, std::string("\xff\xc3\x28\x1e"));
|
||||
check("relay frame to server", wrapped.substr(0, 2) == "\xfbR" && wrapped.substr(2, 8) == token &&
|
||||
wrapped.substr(10, 2) == be16s(41000) && wrapped.size() == 16);
|
||||
int port = 0;
|
||||
check("relay frame from server",
|
||||
relay_unwrap(std::string("\xfbr") + be16s(41001) + "dg", port, out) && port == 41001 && out == "dg");
|
||||
|
||||
// STUN
|
||||
std::string txid;
|
||||
const std::string req = stun_request("", txid);
|
||||
check("stun request shape", req.substr(0, 4) == be16s(1) + be16s(16) && req.substr(4, 4) == "\x21\x12\xa4\x42" &&
|
||||
req.substr(20, 4) == std::string("\x81\x00\x00\x0c", 4) && req.substr(24, 4) == "bbr1");
|
||||
const uint32_t addr = (5u << 24) | (6u << 16) | (7u << 8) | 8u;
|
||||
const uint32_t xaddr = addr ^ ((static_cast<uint8_t>(txid[0]) << 24) | (static_cast<uint8_t>(txid[1]) << 16) |
|
||||
(static_cast<uint8_t>(txid[2]) << 8) | static_cast<uint8_t>(txid[3]));
|
||||
const uint16_t xport = static_cast<uint16_t>(4321 ^ ((static_cast<uint8_t>(txid[0]) << 8) | static_cast<uint8_t>(txid[1])));
|
||||
std::string attr = be16s(0x0020) + be16s(8) + std::string("\x00\x01", 2) + be16s(xport) + be16s(xaddr >> 16) +
|
||||
be16s(xaddr & 0xFFFF);
|
||||
const std::string resp = be16s(0x0101) + be16s(static_cast<uint16_t>(attr.size())) + txid + attr;
|
||||
StunAnswer a;
|
||||
check("stun xor-mapped parsed", stun_parse(resp, txid, a) && a.mapped.ip == "5.6.7.8" && a.mapped.port == 4321,
|
||||
a.mapped.str());
|
||||
check("stun wrong txid rejected", !stun_parse(resp, std::string(16, '\0'), a));
|
||||
|
||||
// signaling, answered for the bbhost peer
|
||||
PeerSignaling ps("hunter", 2, "porthost", 1, 777);
|
||||
auto r = ps.handle(handshake(HS_OFFER, "porthost", "hunter", 5, 0));
|
||||
check("Offer -> Accept from the peer",
|
||||
r.size() == 1 && r[0].size() == 62 && static_cast<uint8_t>(r[0][5]) == HS_ACCEPT &&
|
||||
r[0].compare(6, 6, "hunter") == 0 && r[0].compare(22, 8, "porthost") == 0 && le<uint32_t>(r[0], 42) == 5 &&
|
||||
le<uint32_t>(r[0], 46) == 0);
|
||||
r = ps.handle(handshake(HS_CHECK, "porthost", "hunter", 7, 123456));
|
||||
check("Check -> CheckAck echoes nonce",
|
||||
r.size() == 1 && static_cast<uint8_t>(r[0][5]) == HS_CHECK_ACK && le<uint64_t>(r[0], 54) == 123456);
|
||||
r = ps.handle(control(CTL_ACTIVATION_REQUEST, "porthost", "hunter"));
|
||||
check("ActivationRequest -> ActivationAck", r.size() == 1 && r[0].size() == 50 && static_cast<uint8_t>(r[0][5]) == CTL_ACTIVATION_ACK);
|
||||
const auto first = ps.handle(control(CTL_ESTABLISHED, "porthost", "hunter"));
|
||||
const auto second = ps.handle(control(CTL_ESTABLISHED, "porthost", "hunter"));
|
||||
check("Established answered once", first.size() == 1 && second.empty());
|
||||
r = ps.handle(m2(HS_OFFER, 777, 1, 2, "porthost", 0));
|
||||
check("matching2 Offer -> Accept, member ids swapped",
|
||||
r.size() == 1 && r[0].size() == 50 && static_cast<uint8_t>(r[0][5]) == HS_ACCEPT && le<uint64_t>(r[0], 6) == 777 &&
|
||||
le<uint16_t>(r[0], 14) == 2 && le<uint16_t>(r[0], 16) == 1 && r[0].compare(18, 6, "hunter") == 0);
|
||||
r = ps.handle(m2(HS_CHECK, 777, 1, 2, "porthost", 99));
|
||||
check("matching2 Check -> CheckAck", r.size() == 1 && static_cast<uint8_t>(r[0][5]) == HS_CHECK_ACK && le<uint64_t>(r[0], 42) == 99);
|
||||
PeerSignaling provisional("guest", -29999, "porthost", 1, 777);
|
||||
const auto opening = provisional.opening();
|
||||
check("a provisional (negative) key opens without matching2",
|
||||
opening.size() == 1 && static_cast<uint8_t>(opening[0][5]) == CTL_ACTIVATION_REQUEST &&
|
||||
provisional.conn_id == 0x4242);
|
||||
r = ps.handle(std::string("SHAD\x05", 5) + std::string("\x03\x00\x00\x00", 4) + std::string("\x2b\x02\x00\x00\x00\x00\x00\x00", 8));
|
||||
check("echo ping -> pong", r.size() == 1 && r[0].size() == 17 && static_cast<uint8_t>(r[0][4]) == T_ECHO_PONG &&
|
||||
le<uint32_t>(r[0], 5) == 3 && le<uint64_t>(r[0], 9) == 555);
|
||||
|
||||
// protobuf + shadNet framing
|
||||
pb::Writer w;
|
||||
w.s(1, "npid").u(2, 300).flag(4, true).u(5, 0).msg(6, "");
|
||||
pb::Message m(w.str());
|
||||
check("protobuf round trip", m.ok() && m.s(1) == "npid" && m.u(2) == 300 && m.u(4) == 1 && !m.has(5) && m.has(6));
|
||||
std::string blob;
|
||||
check("proto blob framing", shadnet::parse_proto_blob(shadnet::proto_blob("abc"), blob) && blob == "abc");
|
||||
shadnet::Header h;
|
||||
check("packet header", shadnet::parse_header(shadnet::build_reply(101, 7, 0, "xy"), h) && h.type == shadnet::Reply &&
|
||||
h.command == 101 && h.size == 18 && h.packet_id == 7);
|
||||
|
||||
// account id: the values the Python bridge gave these names
|
||||
check("account id matches the Python bridge (porthost)", account_id_for("porthost") == 4562636682122493ull,
|
||||
std::to_string(account_id_for("porthost")));
|
||||
check("account id matches the Python bridge (tester01)", account_id_for("tester01") == 4533768973479740ull,
|
||||
std::to_string(account_id_for("tester01")));
|
||||
|
||||
#ifdef _WIN32 // bbport for Windows' folder (setup.cpp); on Linux the launcher routes the game
|
||||
// where bbport is found: chosen > the folder bbbridge is in > the launcher's game folder
|
||||
{
|
||||
const fs::path root = fs::temp_directory_path() / L"bbbridge-unit-find";
|
||||
std::error_code ec;
|
||||
fs::remove_all(root, ec);
|
||||
const fs::path bbport_dir = root / L"bloodborne", other = root / L"other", games = root / L"games";
|
||||
for (const auto& d : {bbport_dir / L"bbbridge", other, games / L"CUSA03173"}) fs::create_directories(d);
|
||||
std::ofstream(bbport_dir / L"Bloodborne.exe") << "x";
|
||||
std::ofstream(games / L"Play Bloodborne.exe") << "x";
|
||||
const std::string game_dir = narrow((games / L"CUSA03173").wstring());
|
||||
BbportSource src{};
|
||||
check("bbport: the folder bbbridge is in",
|
||||
find_bbport(bbport_dir / L"bbbridge", {}, game_dir, &src) == bbport_dir && src == BbportSource::ExeFolder);
|
||||
check("bbport: bbbridge.exe right next to Bloodborne.exe",
|
||||
find_bbport(bbport_dir, {}, "", &src) == bbport_dir && src == BbportSource::ExeFolder);
|
||||
check("bbport: a chosen folder that is gone falls back",
|
||||
find_bbport(bbport_dir / L"bbbridge", other, game_dir, &src) == bbport_dir && src == BbportSource::ExeFolder);
|
||||
check("bbport: the chosen folder wins",
|
||||
find_bbport(bbport_dir / L"bbbridge", games, "", &src) == games && src == BbportSource::Chosen);
|
||||
check("bbport: from the launcher's game folder",
|
||||
find_bbport(other / L"bbbridge", {}, game_dir, &src) == games && src == BbportSource::Launcher);
|
||||
check("bbport: not found",
|
||||
find_bbport(other / L"bbbridge", {}, "", &src).empty() && src == BbportSource::None);
|
||||
fs::remove_all(root, ec);
|
||||
}
|
||||
#endif
|
||||
|
||||
std::printf("\n%d passed, %d failed\n", g_pass, g_fail);
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-2024 Omar Cornut
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
//-----------------------------------------------------------------------------
|
||||
// DEAR IMGUI COMPILE-TIME OPTIONS
|
||||
// Runtime options (clipboard callbacks, enabling various features, etc.) can generally be set via the ImGuiIO structure.
|
||||
// You can use ImGui::SetAllocatorFunctions() before calling ImGui::CreateContext() to rewire memory allocation functions.
|
||||
//-----------------------------------------------------------------------------
|
||||
// A) You may edit imconfig.h (and not overwrite it when updating Dear ImGui, or maintain a patch/rebased branch with your modifications to it)
|
||||
// B) or '#define IMGUI_USER_CONFIG "my_imgui_config.h"' in your project and then add directives in your own file without touching this template.
|
||||
//-----------------------------------------------------------------------------
|
||||
// You need to make sure that configuration settings are defined consistently _everywhere_ Dear ImGui is used, which include the imgui*.cpp
|
||||
// files but also _any_ of your code that uses Dear ImGui. This is because some compile-time options have an affect on data structures.
|
||||
// Defining those options in imconfig.h will ensure every compilation unit gets to see the same data structure layouts.
|
||||
// Call IMGUI_CHECKVERSION() from your .cpp file to verify that the data structures your files are using are matching the ones imgui.cpp is using.
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
#pragma once
|
||||
|
||||
//---- Define assertion handler. Defaults to calling assert().
|
||||
// If your macro uses multiple statements, make sure is enclosed in a 'do { .. } while (0)' block so it can be used as a single statement.
|
||||
//#define IM_ASSERT(_EXPR) MyAssert(_EXPR)
|
||||
//#define IM_ASSERT(_EXPR) ((void)(_EXPR)) // Disable asserts
|
||||
|
||||
//---- Define attributes of all API symbols declarations, e.g. for DLL under Windows
|
||||
// Using Dear ImGui via a shared library is not recommended, because of function call overhead and because we don't guarantee backward nor forward ABI compatibility.
|
||||
// - Windows DLL users: heaps and globals are not shared across DLL boundaries! You will need to call SetCurrentContext() + SetAllocatorFunctions()
|
||||
// for each static/DLL boundary you are calling from. Read "Context and Memory Allocators" section of imgui.cpp for more details.
|
||||
//#define IMGUI_API __declspec(dllexport) // MSVC Windows: DLL export
|
||||
//#define IMGUI_API __declspec(dllimport) // MSVC Windows: DLL import
|
||||
//#define IMGUI_API __attribute__((visibility("default"))) // GCC/Clang: override visibility when set is hidden
|
||||
|
||||
//---- Don't define obsolete functions/enums/behaviors. Consider enabling from time to time after updating to clean your code of obsolete function/names.
|
||||
//#define IMGUI_DISABLE_OBSOLETE_FUNCTIONS
|
||||
|
||||
//---- Disable all of Dear ImGui or don't implement standard windows/tools.
|
||||
// It is very strongly recommended to NOT disable the demo windows and debug tool during development. They are extremely useful in day to day work. Please read comments in imgui_demo.cpp.
|
||||
//#define IMGUI_DISABLE // Disable everything: all headers and source files will be empty.
|
||||
//#define IMGUI_DISABLE_DEMO_WINDOWS // Disable demo windows: ShowDemoWindow()/ShowStyleEditor() will be empty.
|
||||
//#define IMGUI_DISABLE_DEBUG_TOOLS // Disable metrics/debugger and other debug tools: ShowMetricsWindow(), ShowDebugLogWindow() and ShowIDStackToolWindow() will be empty.
|
||||
|
||||
//---- Don't implement some functions to reduce linkage requirements.
|
||||
//#define IMGUI_DISABLE_WIN32_DEFAULT_CLIPBOARD_FUNCTIONS // [Win32] Don't implement default clipboard handler. Won't use and link with OpenClipboard/GetClipboardData/CloseClipboard etc. (user32.lib/.a, kernel32.lib/.a)
|
||||
//#define IMGUI_ENABLE_WIN32_DEFAULT_IME_FUNCTIONS // [Win32] [Default with Visual Studio] Implement default IME handler (require imm32.lib/.a, auto-link for Visual Studio, -limm32 on command-line for MinGW)
|
||||
//#define IMGUI_DISABLE_WIN32_DEFAULT_IME_FUNCTIONS // [Win32] [Default with non-Visual Studio compilers] Don't implement default IME handler (won't require imm32.lib/.a)
|
||||
//#define IMGUI_DISABLE_WIN32_FUNCTIONS // [Win32] Won't use and link with any Win32 function (clipboard, IME).
|
||||
//#define IMGUI_ENABLE_OSX_DEFAULT_CLIPBOARD_FUNCTIONS // [OSX] Implement default OSX clipboard handler (need to link with '-framework ApplicationServices', this is why this is not the default).
|
||||
//#define IMGUI_DISABLE_DEFAULT_SHELL_FUNCTIONS // Don't implement default platform_io.Platform_OpenInShellFn() handler (Win32: ShellExecute(), require shell32.lib/.a, Mac/Linux: use system("")).
|
||||
//#define IMGUI_DISABLE_DEFAULT_FORMAT_FUNCTIONS // Don't implement ImFormatString/ImFormatStringV so you can implement them yourself (e.g. if you don't want to link with vsnprintf)
|
||||
//#define IMGUI_DISABLE_DEFAULT_MATH_FUNCTIONS // Don't implement ImFabs/ImSqrt/ImPow/ImFmod/ImCos/ImSin/ImAcos/ImAtan2 so you can implement them yourself.
|
||||
//#define IMGUI_DISABLE_FILE_FUNCTIONS // Don't implement ImFileOpen/ImFileClose/ImFileRead/ImFileWrite and ImFileHandle at all (replace them with dummies)
|
||||
//#define IMGUI_DISABLE_DEFAULT_FILE_FUNCTIONS // Don't implement ImFileOpen/ImFileClose/ImFileRead/ImFileWrite and ImFileHandle so you can implement them yourself if you don't want to link with fopen/fclose/fread/fwrite. This will also disable the LogToTTY() function.
|
||||
//#define IMGUI_DISABLE_DEFAULT_ALLOCATORS // Don't implement default allocators calling malloc()/free() to avoid linking with them. You will need to call ImGui::SetAllocatorFunctions().
|
||||
//#define IMGUI_DISABLE_SSE // Disable use of SSE intrinsics even if available
|
||||
|
||||
//---- Enable Test Engine / Automation features.
|
||||
//#define IMGUI_ENABLE_TEST_ENGINE // Enable imgui_test_engine hooks. Generally set automatically by include "imgui_te_config.h", see Test Engine for details.
|
||||
|
||||
//---- Include imgui_user.h at the end of imgui.h as a convenience
|
||||
// May be convenient for some users to only explicitly include vanilla imgui.h and have extra stuff included.
|
||||
//#define IMGUI_INCLUDE_IMGUI_USER_H
|
||||
//#define IMGUI_USER_H_FILENAME "my_folder/my_imgui_user.h"
|
||||
|
||||
//---- Pack colors to BGRA8 instead of RGBA8 (to avoid converting from one to another)
|
||||
//#define IMGUI_USE_BGRA_PACKED_COLOR
|
||||
|
||||
//---- Use 32-bit for ImWchar (default is 16-bit) to support Unicode planes 1-16. (e.g. point beyond 0xFFFF like emoticons, dingbats, symbols, shapes, ancient languages, etc...)
|
||||
//#define IMGUI_USE_WCHAR32
|
||||
|
||||
//---- Avoid multiple STB libraries implementations, or redefine path/filenames to prioritize another version
|
||||
// By default the embedded implementations are declared static and not available outside of Dear ImGui sources files.
|
||||
//#define IMGUI_STB_TRUETYPE_FILENAME "my_folder/stb_truetype.h"
|
||||
//#define IMGUI_STB_RECT_PACK_FILENAME "my_folder/stb_rect_pack.h"
|
||||
//#define IMGUI_STB_SPRINTF_FILENAME "my_folder/stb_sprintf.h" // only used if IMGUI_USE_STB_SPRINTF is defined.
|
||||
//#define IMGUI_DISABLE_STB_TRUETYPE_IMPLEMENTATION
|
||||
//#define IMGUI_DISABLE_STB_RECT_PACK_IMPLEMENTATION
|
||||
//#define IMGUI_DISABLE_STB_SPRINTF_IMPLEMENTATION // only disabled if IMGUI_USE_STB_SPRINTF is defined.
|
||||
|
||||
//---- Use stb_sprintf.h for a faster implementation of vsnprintf instead of the one from libc (unless IMGUI_DISABLE_DEFAULT_FORMAT_FUNCTIONS is defined)
|
||||
// Compatibility checks of arguments and formats done by clang and GCC will be disabled in order to support the extra formats provided by stb_sprintf.h.
|
||||
//#define IMGUI_USE_STB_SPRINTF
|
||||
|
||||
//---- Use FreeType to build and rasterize the font atlas (instead of stb_truetype which is embedded by default in Dear ImGui)
|
||||
// Requires FreeType headers to be available in the include path. Requires program to be compiled with 'misc/freetype/imgui_freetype.cpp' (in this repository) + the FreeType library (not provided).
|
||||
// On Windows you may use vcpkg with 'vcpkg install freetype --triplet=x64-windows' + 'vcpkg integrate install'.
|
||||
//#define IMGUI_ENABLE_FREETYPE
|
||||
|
||||
//---- Use FreeType + plutosvg or lunasvg to render OpenType SVG fonts (SVGinOT)
|
||||
// Only works in combination with IMGUI_ENABLE_FREETYPE.
|
||||
// - lunasvg is currently easier to acquire/install, as e.g. it is part of vcpkg.
|
||||
// - plutosvg will support more fonts and may load them faster. It currently requires to be built manually but it is fairly easy. See misc/freetype/README for instructions.
|
||||
// - Both require headers to be available in the include path + program to be linked with the library code (not provided).
|
||||
// - (note: lunasvg implementation is based on Freetype's rsvg-port.c which is licensed under CeCILL-C Free Software License Agreement)
|
||||
//#define IMGUI_ENABLE_FREETYPE_PLUTOSVG
|
||||
//#define IMGUI_ENABLE_FREETYPE_LUNASVG
|
||||
|
||||
//---- Use stb_truetype to build and rasterize the font atlas (default)
|
||||
// The only purpose of this define is if you want force compilation of the stb_truetype backend ALONG with the FreeType backend.
|
||||
//#define IMGUI_ENABLE_STB_TRUETYPE
|
||||
|
||||
//---- Define constructor and implicit cast operators to convert back<>forth between your math types and ImVec2/ImVec4.
|
||||
// This will be inlined as part of ImVec2 and ImVec4 class declarations.
|
||||
/*
|
||||
#define IM_VEC2_CLASS_EXTRA \
|
||||
constexpr ImVec2(const MyVec2& f) : x(f.x), y(f.y) {} \
|
||||
operator MyVec2() const { return MyVec2(x,y); }
|
||||
|
||||
#define IM_VEC4_CLASS_EXTRA \
|
||||
constexpr ImVec4(const MyVec4& f) : x(f.x), y(f.y), z(f.z), w(f.w) {} \
|
||||
operator MyVec4() const { return MyVec4(x,y,z,w); }
|
||||
*/
|
||||
//---- ...Or use Dear ImGui's own very basic math operators.
|
||||
//#define IMGUI_DEFINE_MATH_OPERATORS
|
||||
|
||||
//---- Use 32-bit vertex indices (default is 16-bit) is one way to allow large meshes with more than 64K vertices.
|
||||
// Your renderer backend will need to support it (most example renderer backends support both 16/32-bit indices).
|
||||
// Another way to allow large meshes while keeping 16-bit indices is to handle ImDrawCmd::VtxOffset in your renderer.
|
||||
// Read about ImGuiBackendFlags_RendererHasVtxOffset for details.
|
||||
//#define ImDrawIdx unsigned int
|
||||
|
||||
//---- Override ImDrawCallback signature (will need to modify renderer backends accordingly)
|
||||
//struct ImDrawList;
|
||||
//struct ImDrawCmd;
|
||||
//typedef void (*MyImDrawCallback)(const ImDrawList* draw_list, const ImDrawCmd* cmd, void* my_renderer_user_data);
|
||||
//#define ImDrawCallback MyImDrawCallback
|
||||
|
||||
//---- Debug Tools: Macro to break in Debugger (we provide a default implementation of this in the codebase)
|
||||
// (use 'Metrics->Tools->Item Picker' to pick widgets with the mouse and break into them for easy debugging.)
|
||||
//#define IM_DEBUG_BREAK IM_ASSERT(0)
|
||||
//#define IM_DEBUG_BREAK __debugbreak()
|
||||
|
||||
//---- Debug Tools: Enable slower asserts
|
||||
//#define IMGUI_DEBUG_PARANOID
|
||||
|
||||
//---- Tip: You can add extra functions within the ImGui:: namespace from anywhere (e.g. your own sources/header files)
|
||||
/*
|
||||
namespace ImGui
|
||||
{
|
||||
void MyFunction(const char* name, MyMatrix44* mtx);
|
||||
}
|
||||
*/
|
||||
Vendored
+16789
File diff suppressed because it is too large.
Load diff
Vendored
+3714
File diff suppressed because it is too large.
Load diff
+4674
File diff suppressed because it is too large.
Load diff
+618
@@ -0,0 +1,618 @@
|
||||
// dear imgui: Renderer Backend for DirectX11
|
||||
// This needs to be used along with a Platform Backend (e.g. Win32)
|
||||
|
||||
// Implemented features:
|
||||
// [X] Renderer: User texture binding. Use 'ID3D11ShaderResourceView*' as ImTextureID. Read the FAQ about ImTextureID!
|
||||
// [X] Renderer: Large meshes support (64k+ vertices) with 16-bit indices.
|
||||
// [X] Renderer: Expose selected render state for draw callbacks to use. Access in '(ImGui_ImplXXXX_RenderState*)GetPlatformIO().Renderer_RenderState'.
|
||||
|
||||
// You can use unmodified imgui_impl_* files in your project. See examples/ folder for examples of using this.
|
||||
// Prefer including the entire imgui/ repository into your project (either as a copy or as a submodule), and only build the backends you need.
|
||||
// Learn about Dear ImGui:
|
||||
// - FAQ https://dearimgui.com/faq
|
||||
// - Getting Started https://dearimgui.com/getting-started
|
||||
// - Documentation https://dearimgui.com/docs (same as your local docs/ folder).
|
||||
// - Introduction, links and more at the top of imgui.cpp
|
||||
|
||||
// CHANGELOG
|
||||
// (minor and older changes stripped away, please see git history for details)
|
||||
// 2024-10-07: DirectX11: Changed default texture sampler to Clamp instead of Repeat/Wrap.
|
||||
// 2024-10-07: DirectX11: Expose selected render state in ImGui_ImplDX11_RenderState, which you can access in 'void* platform_io.Renderer_RenderState' during draw callbacks.
|
||||
// 2022-10-11: Using 'nullptr' instead of 'NULL' as per our switch to C++11.
|
||||
// 2021-06-29: Reorganized backend to pull data from a single structure to facilitate usage with multiple-contexts (all g_XXXX access changed to bd->XXXX).
|
||||
// 2021-05-19: DirectX11: Replaced direct access to ImDrawCmd::TextureId with a call to ImDrawCmd::GetTexID(). (will become a requirement)
|
||||
// 2021-02-18: DirectX11: Change blending equation to preserve alpha in output buffer.
|
||||
// 2019-08-01: DirectX11: Fixed code querying the Geometry Shader state (would generally error with Debug layer enabled).
|
||||
// 2019-07-21: DirectX11: Backup, clear and restore Geometry Shader is any is bound when calling ImGui_ImplDX11_RenderDrawData. Clearing Hull/Domain/Compute shaders without backup/restore.
|
||||
// 2019-05-29: DirectX11: Added support for large mesh (64K+ vertices), enable ImGuiBackendFlags_RendererHasVtxOffset flag.
|
||||
// 2019-04-30: DirectX11: Added support for special ImDrawCallback_ResetRenderState callback to reset render state.
|
||||
// 2018-12-03: Misc: Added #pragma comment statement to automatically link with d3dcompiler.lib when using D3DCompile().
|
||||
// 2018-11-30: Misc: Setting up io.BackendRendererName so it can be displayed in the About Window.
|
||||
// 2018-08-01: DirectX11: Querying for IDXGIFactory instead of IDXGIFactory1 to increase compatibility.
|
||||
// 2018-07-13: DirectX11: Fixed unreleased resources in Init and Shutdown functions.
|
||||
// 2018-06-08: Misc: Extracted imgui_impl_dx11.cpp/.h away from the old combined DX11+Win32 example.
|
||||
// 2018-06-08: DirectX11: Use draw_data->DisplayPos and draw_data->DisplaySize to setup projection matrix and clipping rectangle.
|
||||
// 2018-02-16: Misc: Obsoleted the io.RenderDrawListsFn callback and exposed ImGui_ImplDX11_RenderDrawData() in the .h file so you can call it yourself.
|
||||
// 2018-02-06: Misc: Removed call to ImGui::Shutdown() which is not available from 1.60 WIP, user needs to call CreateContext/DestroyContext themselves.
|
||||
// 2016-05-07: DirectX11: Disabling depth-write.
|
||||
|
||||
#include "imgui.h"
|
||||
#ifndef IMGUI_DISABLE
|
||||
#include "imgui_impl_dx11.h"
|
||||
|
||||
// DirectX
|
||||
#include <stdio.h>
|
||||
#include <d3d11.h>
|
||||
#include <d3dcompiler.h>
|
||||
#ifdef _MSC_VER
|
||||
#pragma comment(lib, "d3dcompiler") // Automatically link with d3dcompiler.lib as we are using D3DCompile() below.
|
||||
#endif
|
||||
|
||||
// DirectX11 data
|
||||
struct ImGui_ImplDX11_Data
|
||||
{
|
||||
ID3D11Device* pd3dDevice;
|
||||
ID3D11DeviceContext* pd3dDeviceContext;
|
||||
IDXGIFactory* pFactory;
|
||||
ID3D11Buffer* pVB;
|
||||
ID3D11Buffer* pIB;
|
||||
ID3D11VertexShader* pVertexShader;
|
||||
ID3D11InputLayout* pInputLayout;
|
||||
ID3D11Buffer* pVertexConstantBuffer;
|
||||
ID3D11PixelShader* pPixelShader;
|
||||
ID3D11SamplerState* pFontSampler;
|
||||
ID3D11ShaderResourceView* pFontTextureView;
|
||||
ID3D11RasterizerState* pRasterizerState;
|
||||
ID3D11BlendState* pBlendState;
|
||||
ID3D11DepthStencilState* pDepthStencilState;
|
||||
int VertexBufferSize;
|
||||
int IndexBufferSize;
|
||||
|
||||
ImGui_ImplDX11_Data() { memset((void*)this, 0, sizeof(*this)); VertexBufferSize = 5000; IndexBufferSize = 10000; }
|
||||
};
|
||||
|
||||
struct VERTEX_CONSTANT_BUFFER_DX11
|
||||
{
|
||||
float mvp[4][4];
|
||||
};
|
||||
|
||||
// Backend data stored in io.BackendRendererUserData to allow support for multiple Dear ImGui contexts
|
||||
// It is STRONGLY preferred that you use docking branch with multi-viewports (== single Dear ImGui context + multiple windows) instead of multiple Dear ImGui contexts.
|
||||
static ImGui_ImplDX11_Data* ImGui_ImplDX11_GetBackendData()
|
||||
{
|
||||
return ImGui::GetCurrentContext() ? (ImGui_ImplDX11_Data*)ImGui::GetIO().BackendRendererUserData : nullptr;
|
||||
}
|
||||
|
||||
// Functions
|
||||
static void ImGui_ImplDX11_SetupRenderState(ImDrawData* draw_data, ID3D11DeviceContext* device_ctx)
|
||||
{
|
||||
ImGui_ImplDX11_Data* bd = ImGui_ImplDX11_GetBackendData();
|
||||
|
||||
// Setup viewport
|
||||
D3D11_VIEWPORT vp;
|
||||
memset(&vp, 0, sizeof(D3D11_VIEWPORT));
|
||||
vp.Width = draw_data->DisplaySize.x;
|
||||
vp.Height = draw_data->DisplaySize.y;
|
||||
vp.MinDepth = 0.0f;
|
||||
vp.MaxDepth = 1.0f;
|
||||
vp.TopLeftX = vp.TopLeftY = 0;
|
||||
device_ctx->RSSetViewports(1, &vp);
|
||||
|
||||
// Setup shader and vertex buffers
|
||||
unsigned int stride = sizeof(ImDrawVert);
|
||||
unsigned int offset = 0;
|
||||
device_ctx->IASetInputLayout(bd->pInputLayout);
|
||||
device_ctx->IASetVertexBuffers(0, 1, &bd->pVB, &stride, &offset);
|
||||
device_ctx->IASetIndexBuffer(bd->pIB, sizeof(ImDrawIdx) == 2 ? DXGI_FORMAT_R16_UINT : DXGI_FORMAT_R32_UINT, 0);
|
||||
device_ctx->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
|
||||
device_ctx->VSSetShader(bd->pVertexShader, nullptr, 0);
|
||||
device_ctx->VSSetConstantBuffers(0, 1, &bd->pVertexConstantBuffer);
|
||||
device_ctx->PSSetShader(bd->pPixelShader, nullptr, 0);
|
||||
device_ctx->PSSetSamplers(0, 1, &bd->pFontSampler);
|
||||
device_ctx->GSSetShader(nullptr, nullptr, 0);
|
||||
device_ctx->HSSetShader(nullptr, nullptr, 0); // In theory we should backup and restore this as well.. very infrequently used..
|
||||
device_ctx->DSSetShader(nullptr, nullptr, 0); // In theory we should backup and restore this as well.. very infrequently used..
|
||||
device_ctx->CSSetShader(nullptr, nullptr, 0); // In theory we should backup and restore this as well.. very infrequently used..
|
||||
|
||||
// Setup blend state
|
||||
const float blend_factor[4] = { 0.f, 0.f, 0.f, 0.f };
|
||||
device_ctx->OMSetBlendState(bd->pBlendState, blend_factor, 0xffffffff);
|
||||
device_ctx->OMSetDepthStencilState(bd->pDepthStencilState, 0);
|
||||
device_ctx->RSSetState(bd->pRasterizerState);
|
||||
}
|
||||
|
||||
// Render function
|
||||
void ImGui_ImplDX11_RenderDrawData(ImDrawData* draw_data)
|
||||
{
|
||||
// Avoid rendering when minimized
|
||||
if (draw_data->DisplaySize.x <= 0.0f || draw_data->DisplaySize.y <= 0.0f)
|
||||
return;
|
||||
|
||||
ImGui_ImplDX11_Data* bd = ImGui_ImplDX11_GetBackendData();
|
||||
ID3D11DeviceContext* device = bd->pd3dDeviceContext;
|
||||
|
||||
// Create and grow vertex/index buffers if needed
|
||||
if (!bd->pVB || bd->VertexBufferSize < draw_data->TotalVtxCount)
|
||||
{
|
||||
if (bd->pVB) { bd->pVB->Release(); bd->pVB = nullptr; }
|
||||
bd->VertexBufferSize = draw_data->TotalVtxCount + 5000;
|
||||
D3D11_BUFFER_DESC desc;
|
||||
memset(&desc, 0, sizeof(D3D11_BUFFER_DESC));
|
||||
desc.Usage = D3D11_USAGE_DYNAMIC;
|
||||
desc.ByteWidth = bd->VertexBufferSize * sizeof(ImDrawVert);
|
||||
desc.BindFlags = D3D11_BIND_VERTEX_BUFFER;
|
||||
desc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
|
||||
desc.MiscFlags = 0;
|
||||
if (bd->pd3dDevice->CreateBuffer(&desc, nullptr, &bd->pVB) < 0)
|
||||
return;
|
||||
}
|
||||
if (!bd->pIB || bd->IndexBufferSize < draw_data->TotalIdxCount)
|
||||
{
|
||||
if (bd->pIB) { bd->pIB->Release(); bd->pIB = nullptr; }
|
||||
bd->IndexBufferSize = draw_data->TotalIdxCount + 10000;
|
||||
D3D11_BUFFER_DESC desc;
|
||||
memset(&desc, 0, sizeof(D3D11_BUFFER_DESC));
|
||||
desc.Usage = D3D11_USAGE_DYNAMIC;
|
||||
desc.ByteWidth = bd->IndexBufferSize * sizeof(ImDrawIdx);
|
||||
desc.BindFlags = D3D11_BIND_INDEX_BUFFER;
|
||||
desc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
|
||||
if (bd->pd3dDevice->CreateBuffer(&desc, nullptr, &bd->pIB) < 0)
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload vertex/index data into a single contiguous GPU buffer
|
||||
D3D11_MAPPED_SUBRESOURCE vtx_resource, idx_resource;
|
||||
if (device->Map(bd->pVB, 0, D3D11_MAP_WRITE_DISCARD, 0, &vtx_resource) != S_OK)
|
||||
return;
|
||||
if (device->Map(bd->pIB, 0, D3D11_MAP_WRITE_DISCARD, 0, &idx_resource) != S_OK)
|
||||
return;
|
||||
ImDrawVert* vtx_dst = (ImDrawVert*)vtx_resource.pData;
|
||||
ImDrawIdx* idx_dst = (ImDrawIdx*)idx_resource.pData;
|
||||
for (int n = 0; n < draw_data->CmdListsCount; n++)
|
||||
{
|
||||
const ImDrawList* draw_list = draw_data->CmdLists[n];
|
||||
memcpy(vtx_dst, draw_list->VtxBuffer.Data, draw_list->VtxBuffer.Size * sizeof(ImDrawVert));
|
||||
memcpy(idx_dst, draw_list->IdxBuffer.Data, draw_list->IdxBuffer.Size * sizeof(ImDrawIdx));
|
||||
vtx_dst += draw_list->VtxBuffer.Size;
|
||||
idx_dst += draw_list->IdxBuffer.Size;
|
||||
}
|
||||
device->Unmap(bd->pVB, 0);
|
||||
device->Unmap(bd->pIB, 0);
|
||||
|
||||
// Setup orthographic projection matrix into our constant buffer
|
||||
// Our visible imgui space lies from draw_data->DisplayPos (top left) to draw_data->DisplayPos+data_data->DisplaySize (bottom right). DisplayPos is (0,0) for single viewport apps.
|
||||
{
|
||||
D3D11_MAPPED_SUBRESOURCE mapped_resource;
|
||||
if (device->Map(bd->pVertexConstantBuffer, 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped_resource) != S_OK)
|
||||
return;
|
||||
VERTEX_CONSTANT_BUFFER_DX11* constant_buffer = (VERTEX_CONSTANT_BUFFER_DX11*)mapped_resource.pData;
|
||||
float L = draw_data->DisplayPos.x;
|
||||
float R = draw_data->DisplayPos.x + draw_data->DisplaySize.x;
|
||||
float T = draw_data->DisplayPos.y;
|
||||
float B = draw_data->DisplayPos.y + draw_data->DisplaySize.y;
|
||||
float mvp[4][4] =
|
||||
{
|
||||
{ 2.0f/(R-L), 0.0f, 0.0f, 0.0f },
|
||||
{ 0.0f, 2.0f/(T-B), 0.0f, 0.0f },
|
||||
{ 0.0f, 0.0f, 0.5f, 0.0f },
|
||||
{ (R+L)/(L-R), (T+B)/(B-T), 0.5f, 1.0f },
|
||||
};
|
||||
memcpy(&constant_buffer->mvp, mvp, sizeof(mvp));
|
||||
device->Unmap(bd->pVertexConstantBuffer, 0);
|
||||
}
|
||||
|
||||
// Backup DX state that will be modified to restore it afterwards (unfortunately this is very ugly looking and verbose. Close your eyes!)
|
||||
struct BACKUP_DX11_STATE
|
||||
{
|
||||
UINT ScissorRectsCount, ViewportsCount;
|
||||
D3D11_RECT ScissorRects[D3D11_VIEWPORT_AND_SCISSORRECT_OBJECT_COUNT_PER_PIPELINE];
|
||||
D3D11_VIEWPORT Viewports[D3D11_VIEWPORT_AND_SCISSORRECT_OBJECT_COUNT_PER_PIPELINE];
|
||||
ID3D11RasterizerState* RS;
|
||||
ID3D11BlendState* BlendState;
|
||||
FLOAT BlendFactor[4];
|
||||
UINT SampleMask;
|
||||
UINT StencilRef;
|
||||
ID3D11DepthStencilState* DepthStencilState;
|
||||
ID3D11ShaderResourceView* PSShaderResource;
|
||||
ID3D11SamplerState* PSSampler;
|
||||
ID3D11PixelShader* PS;
|
||||
ID3D11VertexShader* VS;
|
||||
ID3D11GeometryShader* GS;
|
||||
UINT PSInstancesCount, VSInstancesCount, GSInstancesCount;
|
||||
ID3D11ClassInstance *PSInstances[256], *VSInstances[256], *GSInstances[256]; // 256 is max according to PSSetShader documentation
|
||||
D3D11_PRIMITIVE_TOPOLOGY PrimitiveTopology;
|
||||
ID3D11Buffer* IndexBuffer, *VertexBuffer, *VSConstantBuffer;
|
||||
UINT IndexBufferOffset, VertexBufferStride, VertexBufferOffset;
|
||||
DXGI_FORMAT IndexBufferFormat;
|
||||
ID3D11InputLayout* InputLayout;
|
||||
};
|
||||
BACKUP_DX11_STATE old = {};
|
||||
old.ScissorRectsCount = old.ViewportsCount = D3D11_VIEWPORT_AND_SCISSORRECT_OBJECT_COUNT_PER_PIPELINE;
|
||||
device->RSGetScissorRects(&old.ScissorRectsCount, old.ScissorRects);
|
||||
device->RSGetViewports(&old.ViewportsCount, old.Viewports);
|
||||
device->RSGetState(&old.RS);
|
||||
device->OMGetBlendState(&old.BlendState, old.BlendFactor, &old.SampleMask);
|
||||
device->OMGetDepthStencilState(&old.DepthStencilState, &old.StencilRef);
|
||||
device->PSGetShaderResources(0, 1, &old.PSShaderResource);
|
||||
device->PSGetSamplers(0, 1, &old.PSSampler);
|
||||
old.PSInstancesCount = old.VSInstancesCount = old.GSInstancesCount = 256;
|
||||
device->PSGetShader(&old.PS, old.PSInstances, &old.PSInstancesCount);
|
||||
device->VSGetShader(&old.VS, old.VSInstances, &old.VSInstancesCount);
|
||||
device->VSGetConstantBuffers(0, 1, &old.VSConstantBuffer);
|
||||
device->GSGetShader(&old.GS, old.GSInstances, &old.GSInstancesCount);
|
||||
|
||||
device->IAGetPrimitiveTopology(&old.PrimitiveTopology);
|
||||
device->IAGetIndexBuffer(&old.IndexBuffer, &old.IndexBufferFormat, &old.IndexBufferOffset);
|
||||
device->IAGetVertexBuffers(0, 1, &old.VertexBuffer, &old.VertexBufferStride, &old.VertexBufferOffset);
|
||||
device->IAGetInputLayout(&old.InputLayout);
|
||||
|
||||
// Setup desired DX state
|
||||
ImGui_ImplDX11_SetupRenderState(draw_data, device);
|
||||
|
||||
// Setup render state structure (for callbacks and custom texture bindings)
|
||||
ImGuiPlatformIO& platform_io = ImGui::GetPlatformIO();
|
||||
ImGui_ImplDX11_RenderState render_state;
|
||||
render_state.Device = bd->pd3dDevice;
|
||||
render_state.DeviceContext = bd->pd3dDeviceContext;
|
||||
render_state.SamplerDefault = bd->pFontSampler;
|
||||
platform_io.Renderer_RenderState = &render_state;
|
||||
|
||||
// Render command lists
|
||||
// (Because we merged all buffers into a single one, we maintain our own offset into them)
|
||||
int global_idx_offset = 0;
|
||||
int global_vtx_offset = 0;
|
||||
ImVec2 clip_off = draw_data->DisplayPos;
|
||||
for (int n = 0; n < draw_data->CmdListsCount; n++)
|
||||
{
|
||||
const ImDrawList* draw_list = draw_data->CmdLists[n];
|
||||
for (int cmd_i = 0; cmd_i < draw_list->CmdBuffer.Size; cmd_i++)
|
||||
{
|
||||
const ImDrawCmd* pcmd = &draw_list->CmdBuffer[cmd_i];
|
||||
if (pcmd->UserCallback != nullptr)
|
||||
{
|
||||
// User callback, registered via ImDrawList::AddCallback()
|
||||
// (ImDrawCallback_ResetRenderState is a special callback value used by the user to request the renderer to reset render state.)
|
||||
if (pcmd->UserCallback == ImDrawCallback_ResetRenderState)
|
||||
ImGui_ImplDX11_SetupRenderState(draw_data, device);
|
||||
else
|
||||
pcmd->UserCallback(draw_list, pcmd);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Project scissor/clipping rectangles into framebuffer space
|
||||
ImVec2 clip_min(pcmd->ClipRect.x - clip_off.x, pcmd->ClipRect.y - clip_off.y);
|
||||
ImVec2 clip_max(pcmd->ClipRect.z - clip_off.x, pcmd->ClipRect.w - clip_off.y);
|
||||
if (clip_max.x <= clip_min.x || clip_max.y <= clip_min.y)
|
||||
continue;
|
||||
|
||||
// Apply scissor/clipping rectangle
|
||||
const D3D11_RECT r = { (LONG)clip_min.x, (LONG)clip_min.y, (LONG)clip_max.x, (LONG)clip_max.y };
|
||||
device->RSSetScissorRects(1, &r);
|
||||
|
||||
// Bind texture, Draw
|
||||
ID3D11ShaderResourceView* texture_srv = (ID3D11ShaderResourceView*)pcmd->GetTexID();
|
||||
device->PSSetShaderResources(0, 1, &texture_srv);
|
||||
device->DrawIndexed(pcmd->ElemCount, pcmd->IdxOffset + global_idx_offset, pcmd->VtxOffset + global_vtx_offset);
|
||||
}
|
||||
}
|
||||
global_idx_offset += draw_list->IdxBuffer.Size;
|
||||
global_vtx_offset += draw_list->VtxBuffer.Size;
|
||||
}
|
||||
platform_io.Renderer_RenderState = NULL;
|
||||
|
||||
// Restore modified DX state
|
||||
device->RSSetScissorRects(old.ScissorRectsCount, old.ScissorRects);
|
||||
device->RSSetViewports(old.ViewportsCount, old.Viewports);
|
||||
device->RSSetState(old.RS); if (old.RS) old.RS->Release();
|
||||
device->OMSetBlendState(old.BlendState, old.BlendFactor, old.SampleMask); if (old.BlendState) old.BlendState->Release();
|
||||
device->OMSetDepthStencilState(old.DepthStencilState, old.StencilRef); if (old.DepthStencilState) old.DepthStencilState->Release();
|
||||
device->PSSetShaderResources(0, 1, &old.PSShaderResource); if (old.PSShaderResource) old.PSShaderResource->Release();
|
||||
device->PSSetSamplers(0, 1, &old.PSSampler); if (old.PSSampler) old.PSSampler->Release();
|
||||
device->PSSetShader(old.PS, old.PSInstances, old.PSInstancesCount); if (old.PS) old.PS->Release();
|
||||
for (UINT i = 0; i < old.PSInstancesCount; i++) if (old.PSInstances[i]) old.PSInstances[i]->Release();
|
||||
device->VSSetShader(old.VS, old.VSInstances, old.VSInstancesCount); if (old.VS) old.VS->Release();
|
||||
device->VSSetConstantBuffers(0, 1, &old.VSConstantBuffer); if (old.VSConstantBuffer) old.VSConstantBuffer->Release();
|
||||
device->GSSetShader(old.GS, old.GSInstances, old.GSInstancesCount); if (old.GS) old.GS->Release();
|
||||
for (UINT i = 0; i < old.VSInstancesCount; i++) if (old.VSInstances[i]) old.VSInstances[i]->Release();
|
||||
device->IASetPrimitiveTopology(old.PrimitiveTopology);
|
||||
device->IASetIndexBuffer(old.IndexBuffer, old.IndexBufferFormat, old.IndexBufferOffset); if (old.IndexBuffer) old.IndexBuffer->Release();
|
||||
device->IASetVertexBuffers(0, 1, &old.VertexBuffer, &old.VertexBufferStride, &old.VertexBufferOffset); if (old.VertexBuffer) old.VertexBuffer->Release();
|
||||
device->IASetInputLayout(old.InputLayout); if (old.InputLayout) old.InputLayout->Release();
|
||||
}
|
||||
|
||||
static void ImGui_ImplDX11_CreateFontsTexture()
|
||||
{
|
||||
// Build texture atlas
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
ImGui_ImplDX11_Data* bd = ImGui_ImplDX11_GetBackendData();
|
||||
unsigned char* pixels;
|
||||
int width, height;
|
||||
io.Fonts->GetTexDataAsRGBA32(&pixels, &width, &height);
|
||||
|
||||
// Upload texture to graphics system
|
||||
{
|
||||
D3D11_TEXTURE2D_DESC desc;
|
||||
ZeroMemory(&desc, sizeof(desc));
|
||||
desc.Width = width;
|
||||
desc.Height = height;
|
||||
desc.MipLevels = 1;
|
||||
desc.ArraySize = 1;
|
||||
desc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
|
||||
desc.SampleDesc.Count = 1;
|
||||
desc.Usage = D3D11_USAGE_DEFAULT;
|
||||
desc.BindFlags = D3D11_BIND_SHADER_RESOURCE;
|
||||
desc.CPUAccessFlags = 0;
|
||||
|
||||
ID3D11Texture2D* pTexture = nullptr;
|
||||
D3D11_SUBRESOURCE_DATA subResource;
|
||||
subResource.pSysMem = pixels;
|
||||
subResource.SysMemPitch = desc.Width * 4;
|
||||
subResource.SysMemSlicePitch = 0;
|
||||
bd->pd3dDevice->CreateTexture2D(&desc, &subResource, &pTexture);
|
||||
IM_ASSERT(pTexture != nullptr);
|
||||
|
||||
// Create texture view
|
||||
D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc;
|
||||
ZeroMemory(&srvDesc, sizeof(srvDesc));
|
||||
srvDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
|
||||
srvDesc.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D;
|
||||
srvDesc.Texture2D.MipLevels = desc.MipLevels;
|
||||
srvDesc.Texture2D.MostDetailedMip = 0;
|
||||
bd->pd3dDevice->CreateShaderResourceView(pTexture, &srvDesc, &bd->pFontTextureView);
|
||||
pTexture->Release();
|
||||
}
|
||||
|
||||
// Store our identifier
|
||||
io.Fonts->SetTexID((ImTextureID)bd->pFontTextureView);
|
||||
|
||||
// Create texture sampler
|
||||
// (Bilinear sampling is required by default. Set 'io.Fonts->Flags |= ImFontAtlasFlags_NoBakedLines' or 'style.AntiAliasedLinesUseTex = false' to allow point/nearest sampling)
|
||||
{
|
||||
D3D11_SAMPLER_DESC desc;
|
||||
ZeroMemory(&desc, sizeof(desc));
|
||||
desc.Filter = D3D11_FILTER_MIN_MAG_MIP_LINEAR;
|
||||
desc.AddressU = D3D11_TEXTURE_ADDRESS_CLAMP;
|
||||
desc.AddressV = D3D11_TEXTURE_ADDRESS_CLAMP;
|
||||
desc.AddressW = D3D11_TEXTURE_ADDRESS_CLAMP;
|
||||
desc.MipLODBias = 0.f;
|
||||
desc.ComparisonFunc = D3D11_COMPARISON_ALWAYS;
|
||||
desc.MinLOD = 0.f;
|
||||
desc.MaxLOD = 0.f;
|
||||
bd->pd3dDevice->CreateSamplerState(&desc, &bd->pFontSampler);
|
||||
}
|
||||
}
|
||||
|
||||
bool ImGui_ImplDX11_CreateDeviceObjects()
|
||||
{
|
||||
ImGui_ImplDX11_Data* bd = ImGui_ImplDX11_GetBackendData();
|
||||
if (!bd->pd3dDevice)
|
||||
return false;
|
||||
if (bd->pFontSampler)
|
||||
ImGui_ImplDX11_InvalidateDeviceObjects();
|
||||
|
||||
// By using D3DCompile() from <d3dcompiler.h> / d3dcompiler.lib, we introduce a dependency to a given version of d3dcompiler_XX.dll (see D3DCOMPILER_DLL_A)
|
||||
// If you would like to use this DX11 sample code but remove this dependency you can:
|
||||
// 1) compile once, save the compiled shader blobs into a file or source code and pass them to CreateVertexShader()/CreatePixelShader() [preferred solution]
|
||||
// 2) use code to detect any version of the DLL and grab a pointer to D3DCompile from the DLL.
|
||||
// See https://github.com/ocornut/imgui/pull/638 for sources and details.
|
||||
|
||||
// Create the vertex shader
|
||||
{
|
||||
static const char* vertexShader =
|
||||
"cbuffer vertexBuffer : register(b0) \
|
||||
{\
|
||||
float4x4 ProjectionMatrix; \
|
||||
};\
|
||||
struct VS_INPUT\
|
||||
{\
|
||||
float2 pos : POSITION;\
|
||||
float4 col : COLOR0;\
|
||||
float2 uv : TEXCOORD0;\
|
||||
};\
|
||||
\
|
||||
struct PS_INPUT\
|
||||
{\
|
||||
float4 pos : SV_POSITION;\
|
||||
float4 col : COLOR0;\
|
||||
float2 uv : TEXCOORD0;\
|
||||
};\
|
||||
\
|
||||
PS_INPUT main(VS_INPUT input)\
|
||||
{\
|
||||
PS_INPUT output;\
|
||||
output.pos = mul( ProjectionMatrix, float4(input.pos.xy, 0.f, 1.f));\
|
||||
output.col = input.col;\
|
||||
output.uv = input.uv;\
|
||||
return output;\
|
||||
}";
|
||||
|
||||
ID3DBlob* vertexShaderBlob;
|
||||
if (FAILED(D3DCompile(vertexShader, strlen(vertexShader), nullptr, nullptr, nullptr, "main", "vs_4_0", 0, 0, &vertexShaderBlob, nullptr)))
|
||||
return false; // NB: Pass ID3DBlob* pErrorBlob to D3DCompile() to get error showing in (const char*)pErrorBlob->GetBufferPointer(). Make sure to Release() the blob!
|
||||
if (bd->pd3dDevice->CreateVertexShader(vertexShaderBlob->GetBufferPointer(), vertexShaderBlob->GetBufferSize(), nullptr, &bd->pVertexShader) != S_OK)
|
||||
{
|
||||
vertexShaderBlob->Release();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create the input layout
|
||||
D3D11_INPUT_ELEMENT_DESC local_layout[] =
|
||||
{
|
||||
{ "POSITION", 0, DXGI_FORMAT_R32G32_FLOAT, 0, (UINT)offsetof(ImDrawVert, pos), D3D11_INPUT_PER_VERTEX_DATA, 0 },
|
||||
{ "TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, 0, (UINT)offsetof(ImDrawVert, uv), D3D11_INPUT_PER_VERTEX_DATA, 0 },
|
||||
{ "COLOR", 0, DXGI_FORMAT_R8G8B8A8_UNORM, 0, (UINT)offsetof(ImDrawVert, col), D3D11_INPUT_PER_VERTEX_DATA, 0 },
|
||||
};
|
||||
if (bd->pd3dDevice->CreateInputLayout(local_layout, 3, vertexShaderBlob->GetBufferPointer(), vertexShaderBlob->GetBufferSize(), &bd->pInputLayout) != S_OK)
|
||||
{
|
||||
vertexShaderBlob->Release();
|
||||
return false;
|
||||
}
|
||||
vertexShaderBlob->Release();
|
||||
|
||||
// Create the constant buffer
|
||||
{
|
||||
D3D11_BUFFER_DESC desc;
|
||||
desc.ByteWidth = sizeof(VERTEX_CONSTANT_BUFFER_DX11);
|
||||
desc.Usage = D3D11_USAGE_DYNAMIC;
|
||||
desc.BindFlags = D3D11_BIND_CONSTANT_BUFFER;
|
||||
desc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
|
||||
desc.MiscFlags = 0;
|
||||
bd->pd3dDevice->CreateBuffer(&desc, nullptr, &bd->pVertexConstantBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the pixel shader
|
||||
{
|
||||
static const char* pixelShader =
|
||||
"struct PS_INPUT\
|
||||
{\
|
||||
float4 pos : SV_POSITION;\
|
||||
float4 col : COLOR0;\
|
||||
float2 uv : TEXCOORD0;\
|
||||
};\
|
||||
sampler sampler0;\
|
||||
Texture2D texture0;\
|
||||
\
|
||||
float4 main(PS_INPUT input) : SV_Target\
|
||||
{\
|
||||
float4 out_col = input.col * texture0.Sample(sampler0, input.uv); \
|
||||
return out_col; \
|
||||
}";
|
||||
|
||||
ID3DBlob* pixelShaderBlob;
|
||||
if (FAILED(D3DCompile(pixelShader, strlen(pixelShader), nullptr, nullptr, nullptr, "main", "ps_4_0", 0, 0, &pixelShaderBlob, nullptr)))
|
||||
return false; // NB: Pass ID3DBlob* pErrorBlob to D3DCompile() to get error showing in (const char*)pErrorBlob->GetBufferPointer(). Make sure to Release() the blob!
|
||||
if (bd->pd3dDevice->CreatePixelShader(pixelShaderBlob->GetBufferPointer(), pixelShaderBlob->GetBufferSize(), nullptr, &bd->pPixelShader) != S_OK)
|
||||
{
|
||||
pixelShaderBlob->Release();
|
||||
return false;
|
||||
}
|
||||
pixelShaderBlob->Release();
|
||||
}
|
||||
|
||||
// Create the blending setup
|
||||
{
|
||||
D3D11_BLEND_DESC desc;
|
||||
ZeroMemory(&desc, sizeof(desc));
|
||||
desc.AlphaToCoverageEnable = false;
|
||||
desc.RenderTarget[0].BlendEnable = true;
|
||||
desc.RenderTarget[0].SrcBlend = D3D11_BLEND_SRC_ALPHA;
|
||||
desc.RenderTarget[0].DestBlend = D3D11_BLEND_INV_SRC_ALPHA;
|
||||
desc.RenderTarget[0].BlendOp = D3D11_BLEND_OP_ADD;
|
||||
desc.RenderTarget[0].SrcBlendAlpha = D3D11_BLEND_ONE;
|
||||
desc.RenderTarget[0].DestBlendAlpha = D3D11_BLEND_INV_SRC_ALPHA;
|
||||
desc.RenderTarget[0].BlendOpAlpha = D3D11_BLEND_OP_ADD;
|
||||
desc.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL;
|
||||
bd->pd3dDevice->CreateBlendState(&desc, &bd->pBlendState);
|
||||
}
|
||||
|
||||
// Create the rasterizer state
|
||||
{
|
||||
D3D11_RASTERIZER_DESC desc;
|
||||
ZeroMemory(&desc, sizeof(desc));
|
||||
desc.FillMode = D3D11_FILL_SOLID;
|
||||
desc.CullMode = D3D11_CULL_NONE;
|
||||
desc.ScissorEnable = true;
|
||||
desc.DepthClipEnable = true;
|
||||
bd->pd3dDevice->CreateRasterizerState(&desc, &bd->pRasterizerState);
|
||||
}
|
||||
|
||||
// Create depth-stencil State
|
||||
{
|
||||
D3D11_DEPTH_STENCIL_DESC desc;
|
||||
ZeroMemory(&desc, sizeof(desc));
|
||||
desc.DepthEnable = false;
|
||||
desc.DepthWriteMask = D3D11_DEPTH_WRITE_MASK_ALL;
|
||||
desc.DepthFunc = D3D11_COMPARISON_ALWAYS;
|
||||
desc.StencilEnable = false;
|
||||
desc.FrontFace.StencilFailOp = desc.FrontFace.StencilDepthFailOp = desc.FrontFace.StencilPassOp = D3D11_STENCIL_OP_KEEP;
|
||||
desc.FrontFace.StencilFunc = D3D11_COMPARISON_ALWAYS;
|
||||
desc.BackFace = desc.FrontFace;
|
||||
bd->pd3dDevice->CreateDepthStencilState(&desc, &bd->pDepthStencilState);
|
||||
}
|
||||
|
||||
ImGui_ImplDX11_CreateFontsTexture();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void ImGui_ImplDX11_InvalidateDeviceObjects()
|
||||
{
|
||||
ImGui_ImplDX11_Data* bd = ImGui_ImplDX11_GetBackendData();
|
||||
if (!bd->pd3dDevice)
|
||||
return;
|
||||
|
||||
if (bd->pFontSampler) { bd->pFontSampler->Release(); bd->pFontSampler = nullptr; }
|
||||
if (bd->pFontTextureView) { bd->pFontTextureView->Release(); bd->pFontTextureView = nullptr; ImGui::GetIO().Fonts->SetTexID(0); } // We copied data->pFontTextureView to io.Fonts->TexID so let's clear that as well.
|
||||
if (bd->pIB) { bd->pIB->Release(); bd->pIB = nullptr; }
|
||||
if (bd->pVB) { bd->pVB->Release(); bd->pVB = nullptr; }
|
||||
if (bd->pBlendState) { bd->pBlendState->Release(); bd->pBlendState = nullptr; }
|
||||
if (bd->pDepthStencilState) { bd->pDepthStencilState->Release(); bd->pDepthStencilState = nullptr; }
|
||||
if (bd->pRasterizerState) { bd->pRasterizerState->Release(); bd->pRasterizerState = nullptr; }
|
||||
if (bd->pPixelShader) { bd->pPixelShader->Release(); bd->pPixelShader = nullptr; }
|
||||
if (bd->pVertexConstantBuffer) { bd->pVertexConstantBuffer->Release(); bd->pVertexConstantBuffer = nullptr; }
|
||||
if (bd->pInputLayout) { bd->pInputLayout->Release(); bd->pInputLayout = nullptr; }
|
||||
if (bd->pVertexShader) { bd->pVertexShader->Release(); bd->pVertexShader = nullptr; }
|
||||
}
|
||||
|
||||
bool ImGui_ImplDX11_Init(ID3D11Device* device, ID3D11DeviceContext* device_context)
|
||||
{
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
IMGUI_CHECKVERSION();
|
||||
IM_ASSERT(io.BackendRendererUserData == nullptr && "Already initialized a renderer backend!");
|
||||
|
||||
// Setup backend capabilities flags
|
||||
ImGui_ImplDX11_Data* bd = IM_NEW(ImGui_ImplDX11_Data)();
|
||||
io.BackendRendererUserData = (void*)bd;
|
||||
io.BackendRendererName = "imgui_impl_dx11";
|
||||
io.BackendFlags |= ImGuiBackendFlags_RendererHasVtxOffset; // We can honor the ImDrawCmd::VtxOffset field, allowing for large meshes.
|
||||
|
||||
// Get factory from device
|
||||
IDXGIDevice* pDXGIDevice = nullptr;
|
||||
IDXGIAdapter* pDXGIAdapter = nullptr;
|
||||
IDXGIFactory* pFactory = nullptr;
|
||||
|
||||
if (device->QueryInterface(IID_PPV_ARGS(&pDXGIDevice)) == S_OK)
|
||||
if (pDXGIDevice->GetParent(IID_PPV_ARGS(&pDXGIAdapter)) == S_OK)
|
||||
if (pDXGIAdapter->GetParent(IID_PPV_ARGS(&pFactory)) == S_OK)
|
||||
{
|
||||
bd->pd3dDevice = device;
|
||||
bd->pd3dDeviceContext = device_context;
|
||||
bd->pFactory = pFactory;
|
||||
}
|
||||
if (pDXGIDevice) pDXGIDevice->Release();
|
||||
if (pDXGIAdapter) pDXGIAdapter->Release();
|
||||
bd->pd3dDevice->AddRef();
|
||||
bd->pd3dDeviceContext->AddRef();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void ImGui_ImplDX11_Shutdown()
|
||||
{
|
||||
ImGui_ImplDX11_Data* bd = ImGui_ImplDX11_GetBackendData();
|
||||
IM_ASSERT(bd != nullptr && "No renderer backend to shutdown, or already shutdown?");
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
|
||||
ImGui_ImplDX11_InvalidateDeviceObjects();
|
||||
if (bd->pFactory) { bd->pFactory->Release(); }
|
||||
if (bd->pd3dDevice) { bd->pd3dDevice->Release(); }
|
||||
if (bd->pd3dDeviceContext) { bd->pd3dDeviceContext->Release(); }
|
||||
io.BackendRendererName = nullptr;
|
||||
io.BackendRendererUserData = nullptr;
|
||||
io.BackendFlags &= ~ImGuiBackendFlags_RendererHasVtxOffset;
|
||||
IM_DELETE(bd);
|
||||
}
|
||||
|
||||
void ImGui_ImplDX11_NewFrame()
|
||||
{
|
||||
ImGui_ImplDX11_Data* bd = ImGui_ImplDX11_GetBackendData();
|
||||
IM_ASSERT(bd != nullptr && "Context or backend not initialized! Did you call ImGui_ImplDX11_Init()?");
|
||||
|
||||
if (!bd->pFontSampler)
|
||||
ImGui_ImplDX11_CreateDeviceObjects();
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
#endif // #ifndef IMGUI_DISABLE
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
// dear imgui: Renderer Backend for DirectX11
|
||||
// This needs to be used along with a Platform Backend (e.g. Win32)
|
||||
|
||||
// Implemented features:
|
||||
// [X] Renderer: User texture binding. Use 'ID3D11ShaderResourceView*' as ImTextureID. Read the FAQ about ImTextureID!
|
||||
// [X] Renderer: Large meshes support (64k+ vertices) with 16-bit indices.
|
||||
// [X] Renderer: Expose selected render state for draw callbacks to use. Access in '(ImGui_ImplXXXX_RenderState*)GetPlatformIO().Renderer_RenderState'.
|
||||
|
||||
// You can use unmodified imgui_impl_* files in your project. See examples/ folder for examples of using this.
|
||||
// Prefer including the entire imgui/ repository into your project (either as a copy or as a submodule), and only build the backends you need.
|
||||
// Learn about Dear ImGui:
|
||||
// - FAQ https://dearimgui.com/faq
|
||||
// - Getting Started https://dearimgui.com/getting-started
|
||||
// - Documentation https://dearimgui.com/docs (same as your local docs/ folder).
|
||||
// - Introduction, links and more at the top of imgui.cpp
|
||||
|
||||
#pragma once
|
||||
#include "imgui.h" // IMGUI_IMPL_API
|
||||
#ifndef IMGUI_DISABLE
|
||||
|
||||
struct ID3D11Device;
|
||||
struct ID3D11DeviceContext;
|
||||
struct ID3D11SamplerState;
|
||||
|
||||
// Follow "Getting Started" link and check examples/ folder to learn about using backends!
|
||||
IMGUI_IMPL_API bool ImGui_ImplDX11_Init(ID3D11Device* device, ID3D11DeviceContext* device_context);
|
||||
IMGUI_IMPL_API void ImGui_ImplDX11_Shutdown();
|
||||
IMGUI_IMPL_API void ImGui_ImplDX11_NewFrame();
|
||||
IMGUI_IMPL_API void ImGui_ImplDX11_RenderDrawData(ImDrawData* draw_data);
|
||||
|
||||
// Use if you want to reset your rendering device without losing Dear ImGui state.
|
||||
IMGUI_IMPL_API bool ImGui_ImplDX11_CreateDeviceObjects();
|
||||
IMGUI_IMPL_API void ImGui_ImplDX11_InvalidateDeviceObjects();
|
||||
|
||||
// [BETA] Selected render state data shared with callbacks.
|
||||
// This is temporarily stored in GetPlatformIO().Renderer_RenderState during the ImGui_ImplDX11_RenderDrawData() call.
|
||||
// (Please open an issue if you feel you need access to more data)
|
||||
struct ImGui_ImplDX11_RenderState
|
||||
{
|
||||
ID3D11Device* Device;
|
||||
ID3D11DeviceContext* DeviceContext;
|
||||
ID3D11SamplerState* SamplerDefault;
|
||||
};
|
||||
|
||||
#endif // #ifndef IMGUI_DISABLE
|
||||
+940
@@ -0,0 +1,940 @@
|
||||
// dear imgui: Platform Backend for Windows (standard windows API for 32-bits AND 64-bits applications)
|
||||
// This needs to be used along with a Renderer (e.g. DirectX11, OpenGL3, Vulkan..)
|
||||
|
||||
// Implemented features:
|
||||
// [X] Platform: Clipboard support (for Win32 this is actually part of core dear imgui)
|
||||
// [X] Platform: Mouse support. Can discriminate Mouse/TouchScreen/Pen.
|
||||
// [X] Platform: Keyboard support. Since 1.87 we are using the io.AddKeyEvent() function. Pass ImGuiKey values to all key functions e.g. ImGui::IsKeyPressed(ImGuiKey_Space). [Legacy VK_* values are obsolete since 1.87 and not supported since 1.91.5]
|
||||
// [X] Platform: Gamepad support. Enabled with 'io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad'.
|
||||
// [X] Platform: Mouse cursor shape and visibility. Disable with 'io.ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange'.
|
||||
|
||||
// You can use unmodified imgui_impl_* files in your project. See examples/ folder for examples of using this.
|
||||
// Prefer including the entire imgui/ repository into your project (either as a copy or as a submodule), and only build the backends you need.
|
||||
// Learn about Dear ImGui:
|
||||
// - FAQ https://dearimgui.com/faq
|
||||
// - Getting Started https://dearimgui.com/getting-started
|
||||
// - Documentation https://dearimgui.com/docs (same as your local docs/ folder).
|
||||
// - Introduction, links and more at the top of imgui.cpp
|
||||
|
||||
// Configuration flags to add in your imconfig file:
|
||||
//#define IMGUI_IMPL_WIN32_DISABLE_GAMEPAD // Disable gamepad support. This was meaningful before <1.81 but we now load XInput dynamically so the option is now less relevant.
|
||||
|
||||
// CHANGELOG
|
||||
// (minor and older changes stripped away, please see git history for details)
|
||||
// 2024-07-08: Inputs: Fixed ImGuiMod_Super being mapped to VK_APPS instead of VK_LWIN||VK_RWIN. (#7768)
|
||||
// 2023-10-05: Inputs: Added support for extra ImGuiKey values: F13 to F24 function keys, app back/forward keys.
|
||||
// 2023-09-25: Inputs: Synthesize key-down event on key-up for VK_SNAPSHOT / ImGuiKey_PrintScreen as Windows doesn't emit it (same behavior as GLFW/SDL).
|
||||
// 2023-09-07: Inputs: Added support for keyboard codepage conversion for when application is compiled in MBCS mode and using a non-Unicode window.
|
||||
// 2023-04-19: Added ImGui_ImplWin32_InitForOpenGL() to facilitate combining raw Win32/Winapi with OpenGL. (#3218)
|
||||
// 2023-04-04: Inputs: Added support for io.AddMouseSourceEvent() to discriminate ImGuiMouseSource_Mouse/ImGuiMouseSource_TouchScreen/ImGuiMouseSource_Pen. (#2702)
|
||||
// 2023-02-15: Inputs: Use WM_NCMOUSEMOVE / WM_NCMOUSELEAVE to track mouse position over non-client area (e.g. OS decorations) when app is not focused. (#6045, #6162)
|
||||
// 2023-02-02: Inputs: Flipping WM_MOUSEHWHEEL (horizontal mouse-wheel) value to match other backends and offer consistent horizontal scrolling direction. (#4019, #6096, #1463)
|
||||
// 2022-10-11: Using 'nullptr' instead of 'NULL' as per our switch to C++11.
|
||||
// 2022-09-28: Inputs: Convert WM_CHAR values with MultiByteToWideChar() when window class was registered as MBCS (not Unicode).
|
||||
// 2022-09-26: Inputs: Renamed ImGuiKey_ModXXX introduced in 1.87 to ImGuiMod_XXX (old names still supported).
|
||||
// 2022-01-26: Inputs: replaced short-lived io.AddKeyModsEvent() (added two weeks ago) with io.AddKeyEvent() using ImGuiKey_ModXXX flags. Sorry for the confusion.
|
||||
// 2021-01-20: Inputs: calling new io.AddKeyAnalogEvent() for gamepad support, instead of writing directly to io.NavInputs[].
|
||||
// 2022-01-17: Inputs: calling new io.AddMousePosEvent(), io.AddMouseButtonEvent(), io.AddMouseWheelEvent() API (1.87+).
|
||||
// 2022-01-17: Inputs: always update key mods next and before a key event (not in NewFrame) to fix input queue with very low framerates.
|
||||
// 2022-01-12: Inputs: Update mouse inputs using WM_MOUSEMOVE/WM_MOUSELEAVE + fallback to provide it when focused but not hovered/captured. More standard and will allow us to pass it to future input queue API.
|
||||
// 2022-01-12: Inputs: Maintain our own copy of MouseButtonsDown mask instead of using ImGui::IsAnyMouseDown() which will be obsoleted.
|
||||
// 2022-01-10: Inputs: calling new io.AddKeyEvent(), io.AddKeyModsEvent() + io.SetKeyEventNativeData() API (1.87+). Support for full ImGuiKey range.
|
||||
// 2021-12-16: Inputs: Fill VK_LCONTROL/VK_RCONTROL/VK_LSHIFT/VK_RSHIFT/VK_LMENU/VK_RMENU for completeness.
|
||||
// 2021-08-17: Calling io.AddFocusEvent() on WM_SETFOCUS/WM_KILLFOCUS messages.
|
||||
// 2021-08-02: Inputs: Fixed keyboard modifiers being reported when host window doesn't have focus.
|
||||
// 2021-07-29: Inputs: MousePos is correctly reported when the host platform window is hovered but not focused (using TrackMouseEvent() to receive WM_MOUSELEAVE events).
|
||||
// 2021-06-29: Reorganized backend to pull data from a single structure to facilitate usage with multiple-contexts (all g_XXXX access changed to bd->XXXX).
|
||||
// 2021-06-08: Fixed ImGui_ImplWin32_EnableDpiAwareness() and ImGui_ImplWin32_GetDpiScaleForMonitor() to handle Windows 8.1/10 features without a manifest (per-monitor DPI, and properly calls SetProcessDpiAwareness() on 8.1).
|
||||
// 2021-03-23: Inputs: Clearing keyboard down array when losing focus (WM_KILLFOCUS).
|
||||
// 2021-02-18: Added ImGui_ImplWin32_EnableAlphaCompositing(). Non Visual Studio users will need to link with dwmapi.lib (MinGW/gcc: use -ldwmapi).
|
||||
// 2021-02-17: Fixed ImGui_ImplWin32_EnableDpiAwareness() attempting to get SetProcessDpiAwareness from shcore.dll on Windows 8 whereas it is only supported on Windows 8.1.
|
||||
// 2021-01-25: Inputs: Dynamically loading XInput DLL.
|
||||
// 2020-12-04: Misc: Fixed setting of io.DisplaySize to invalid/uninitialized data when after hwnd has been closed.
|
||||
// 2020-03-03: Inputs: Calling AddInputCharacterUTF16() to support surrogate pairs leading to codepoint >= 0x10000 (for more complete CJK inputs)
|
||||
// 2020-02-17: Added ImGui_ImplWin32_EnableDpiAwareness(), ImGui_ImplWin32_GetDpiScaleForHwnd(), ImGui_ImplWin32_GetDpiScaleForMonitor() helper functions.
|
||||
// 2020-01-14: Inputs: Added support for #define IMGUI_IMPL_WIN32_DISABLE_GAMEPAD/IMGUI_IMPL_WIN32_DISABLE_LINKING_XINPUT.
|
||||
// 2019-12-05: Inputs: Added support for ImGuiMouseCursor_NotAllowed mouse cursor.
|
||||
// 2019-05-11: Inputs: Don't filter value from WM_CHAR before calling AddInputCharacter().
|
||||
// 2019-01-17: Misc: Using GetForegroundWindow()+IsChild() instead of GetActiveWindow() to be compatible with windows created in a different thread or parent.
|
||||
// 2019-01-17: Inputs: Added support for mouse buttons 4 and 5 via WM_XBUTTON* messages.
|
||||
// 2019-01-15: Inputs: Added support for XInput gamepads (if ImGuiConfigFlags_NavEnableGamepad is set by user application).
|
||||
// 2018-11-30: Misc: Setting up io.BackendPlatformName so it can be displayed in the About Window.
|
||||
// 2018-06-29: Inputs: Added support for the ImGuiMouseCursor_Hand cursor.
|
||||
// 2018-06-10: Inputs: Fixed handling of mouse wheel messages to support fine position messages (typically sent by track-pads).
|
||||
// 2018-06-08: Misc: Extracted imgui_impl_win32.cpp/.h away from the old combined DX9/DX10/DX11/DX12 examples.
|
||||
// 2018-03-20: Misc: Setup io.BackendFlags ImGuiBackendFlags_HasMouseCursors and ImGuiBackendFlags_HasSetMousePos flags + honor ImGuiConfigFlags_NoMouseCursorChange flag.
|
||||
// 2018-02-20: Inputs: Added support for mouse cursors (ImGui::GetMouseCursor() value and WM_SETCURSOR message handling).
|
||||
// 2018-02-06: Inputs: Added mapping for ImGuiKey_Space.
|
||||
// 2018-02-06: Inputs: Honoring the io.WantSetMousePos by repositioning the mouse (when using navigation and ImGuiConfigFlags_NavMoveMouse is set).
|
||||
// 2018-02-06: Misc: Removed call to ImGui::Shutdown() which is not available from 1.60 WIP, user needs to call CreateContext/DestroyContext themselves.
|
||||
// 2018-01-20: Inputs: Added Horizontal Mouse Wheel support.
|
||||
// 2018-01-08: Inputs: Added mapping for ImGuiKey_Insert.
|
||||
// 2018-01-05: Inputs: Added WM_LBUTTONDBLCLK double-click handlers for window classes with the CS_DBLCLKS flag.
|
||||
// 2017-10-23: Inputs: Added WM_SYSKEYDOWN / WM_SYSKEYUP handlers so e.g. the VK_MENU key can be read.
|
||||
// 2017-10-23: Inputs: Using Win32 ::SetCapture/::GetCapture() to retrieve mouse positions outside the client area when dragging.
|
||||
// 2016-11-12: Inputs: Only call Win32 ::SetCursor(nullptr) when io.MouseDrawCursor is set.
|
||||
|
||||
#include "imgui.h"
|
||||
#ifndef IMGUI_DISABLE
|
||||
#include "imgui_impl_win32.h"
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#include <windowsx.h> // GET_X_LPARAM(), GET_Y_LPARAM()
|
||||
#include <tchar.h>
|
||||
#include <dwmapi.h>
|
||||
|
||||
// Using XInput for gamepad (will load DLL dynamically)
|
||||
#ifndef IMGUI_IMPL_WIN32_DISABLE_GAMEPAD
|
||||
#include <xinput.h>
|
||||
typedef DWORD(WINAPI* PFN_XInputGetCapabilities)(DWORD, DWORD, XINPUT_CAPABILITIES*);
|
||||
typedef DWORD(WINAPI* PFN_XInputGetState)(DWORD, XINPUT_STATE*);
|
||||
#endif
|
||||
|
||||
// Clang/GCC warnings with -Weverything
|
||||
#if defined(__clang__)
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wcast-function-type" // warning: cast between incompatible function types (for loader)
|
||||
#endif
|
||||
#if defined(__GNUC__)
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wpragmas" // warning: unknown option after '#pragma GCC diagnostic' kind
|
||||
#pragma GCC diagnostic ignored "-Wcast-function-type" // warning: cast between incompatible function types (for loader)
|
||||
#endif
|
||||
|
||||
struct ImGui_ImplWin32_Data
|
||||
{
|
||||
HWND hWnd;
|
||||
HWND MouseHwnd;
|
||||
int MouseTrackedArea; // 0: not tracked, 1: client area, 2: non-client area
|
||||
int MouseButtonsDown;
|
||||
INT64 Time;
|
||||
INT64 TicksPerSecond;
|
||||
ImGuiMouseCursor LastMouseCursor;
|
||||
UINT32 KeyboardCodePage;
|
||||
|
||||
#ifndef IMGUI_IMPL_WIN32_DISABLE_GAMEPAD
|
||||
bool HasGamepad;
|
||||
bool WantUpdateHasGamepad;
|
||||
HMODULE XInputDLL;
|
||||
PFN_XInputGetCapabilities XInputGetCapabilities;
|
||||
PFN_XInputGetState XInputGetState;
|
||||
#endif
|
||||
|
||||
ImGui_ImplWin32_Data() { memset((void*)this, 0, sizeof(*this)); }
|
||||
};
|
||||
|
||||
// Backend data stored in io.BackendPlatformUserData to allow support for multiple Dear ImGui contexts
|
||||
// It is STRONGLY preferred that you use docking branch with multi-viewports (== single Dear ImGui context + multiple windows) instead of multiple Dear ImGui contexts.
|
||||
// FIXME: multi-context support is not well tested and probably dysfunctional in this backend.
|
||||
// FIXME: some shared resources (mouse cursor shape, gamepad) are mishandled when using multi-context.
|
||||
static ImGui_ImplWin32_Data* ImGui_ImplWin32_GetBackendData()
|
||||
{
|
||||
return ImGui::GetCurrentContext() ? (ImGui_ImplWin32_Data*)ImGui::GetIO().BackendPlatformUserData : nullptr;
|
||||
}
|
||||
static ImGui_ImplWin32_Data* ImGui_ImplWin32_GetBackendData(ImGuiIO& io)
|
||||
{
|
||||
return (ImGui_ImplWin32_Data*)io.BackendPlatformUserData;
|
||||
}
|
||||
|
||||
// Functions
|
||||
static void ImGui_ImplWin32_UpdateKeyboardCodePage(ImGuiIO& io)
|
||||
{
|
||||
// Retrieve keyboard code page, required for handling of non-Unicode Windows.
|
||||
ImGui_ImplWin32_Data* bd = ImGui_ImplWin32_GetBackendData(io);
|
||||
HKL keyboard_layout = ::GetKeyboardLayout(0);
|
||||
LCID keyboard_lcid = MAKELCID(HIWORD(keyboard_layout), SORT_DEFAULT);
|
||||
if (::GetLocaleInfoA(keyboard_lcid, (LOCALE_RETURN_NUMBER | LOCALE_IDEFAULTANSICODEPAGE), (LPSTR)&bd->KeyboardCodePage, sizeof(bd->KeyboardCodePage)) == 0)
|
||||
bd->KeyboardCodePage = CP_ACP; // Fallback to default ANSI code page when fails.
|
||||
}
|
||||
|
||||
static bool ImGui_ImplWin32_InitEx(void* hwnd, bool platform_has_own_dc)
|
||||
{
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
IMGUI_CHECKVERSION();
|
||||
IM_ASSERT(io.BackendPlatformUserData == nullptr && "Already initialized a platform backend!");
|
||||
|
||||
INT64 perf_frequency, perf_counter;
|
||||
if (!::QueryPerformanceFrequency((LARGE_INTEGER*)&perf_frequency))
|
||||
return false;
|
||||
if (!::QueryPerformanceCounter((LARGE_INTEGER*)&perf_counter))
|
||||
return false;
|
||||
|
||||
// Setup backend capabilities flags
|
||||
ImGui_ImplWin32_Data* bd = IM_NEW(ImGui_ImplWin32_Data)();
|
||||
io.BackendPlatformUserData = (void*)bd;
|
||||
io.BackendPlatformName = "imgui_impl_win32";
|
||||
io.BackendFlags |= ImGuiBackendFlags_HasMouseCursors; // We can honor GetMouseCursor() values (optional)
|
||||
io.BackendFlags |= ImGuiBackendFlags_HasSetMousePos; // We can honor io.WantSetMousePos requests (optional, rarely used)
|
||||
|
||||
bd->hWnd = (HWND)hwnd;
|
||||
bd->TicksPerSecond = perf_frequency;
|
||||
bd->Time = perf_counter;
|
||||
bd->LastMouseCursor = ImGuiMouseCursor_COUNT;
|
||||
ImGui_ImplWin32_UpdateKeyboardCodePage(io);
|
||||
|
||||
// Set platform dependent data in viewport
|
||||
ImGuiViewport* main_viewport = ImGui::GetMainViewport();
|
||||
main_viewport->PlatformHandle = main_viewport->PlatformHandleRaw = (void*)bd->hWnd;
|
||||
IM_UNUSED(platform_has_own_dc); // Used in 'docking' branch
|
||||
|
||||
// Dynamically load XInput library
|
||||
#ifndef IMGUI_IMPL_WIN32_DISABLE_GAMEPAD
|
||||
bd->WantUpdateHasGamepad = true;
|
||||
const char* xinput_dll_names[] =
|
||||
{
|
||||
"xinput1_4.dll", // Windows 8+
|
||||
"xinput1_3.dll", // DirectX SDK
|
||||
"xinput9_1_0.dll", // Windows Vista, Windows 7
|
||||
"xinput1_2.dll", // DirectX SDK
|
||||
"xinput1_1.dll" // DirectX SDK
|
||||
};
|
||||
for (int n = 0; n < IM_ARRAYSIZE(xinput_dll_names); n++)
|
||||
if (HMODULE dll = ::LoadLibraryA(xinput_dll_names[n]))
|
||||
{
|
||||
bd->XInputDLL = dll;
|
||||
bd->XInputGetCapabilities = (PFN_XInputGetCapabilities)::GetProcAddress(dll, "XInputGetCapabilities");
|
||||
bd->XInputGetState = (PFN_XInputGetState)::GetProcAddress(dll, "XInputGetState");
|
||||
break;
|
||||
}
|
||||
#endif // IMGUI_IMPL_WIN32_DISABLE_GAMEPAD
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
IMGUI_IMPL_API bool ImGui_ImplWin32_Init(void* hwnd)
|
||||
{
|
||||
return ImGui_ImplWin32_InitEx(hwnd, false);
|
||||
}
|
||||
|
||||
IMGUI_IMPL_API bool ImGui_ImplWin32_InitForOpenGL(void* hwnd)
|
||||
{
|
||||
// OpenGL needs CS_OWNDC
|
||||
return ImGui_ImplWin32_InitEx(hwnd, true);
|
||||
}
|
||||
|
||||
void ImGui_ImplWin32_Shutdown()
|
||||
{
|
||||
ImGui_ImplWin32_Data* bd = ImGui_ImplWin32_GetBackendData();
|
||||
IM_ASSERT(bd != nullptr && "No platform backend to shutdown, or already shutdown?");
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
|
||||
// Unload XInput library
|
||||
#ifndef IMGUI_IMPL_WIN32_DISABLE_GAMEPAD
|
||||
if (bd->XInputDLL)
|
||||
::FreeLibrary(bd->XInputDLL);
|
||||
#endif // IMGUI_IMPL_WIN32_DISABLE_GAMEPAD
|
||||
|
||||
io.BackendPlatformName = nullptr;
|
||||
io.BackendPlatformUserData = nullptr;
|
||||
io.BackendFlags &= ~(ImGuiBackendFlags_HasMouseCursors | ImGuiBackendFlags_HasSetMousePos | ImGuiBackendFlags_HasGamepad);
|
||||
IM_DELETE(bd);
|
||||
}
|
||||
|
||||
static bool ImGui_ImplWin32_UpdateMouseCursor(ImGuiIO& io, ImGuiMouseCursor imgui_cursor)
|
||||
{
|
||||
if (io.ConfigFlags & ImGuiConfigFlags_NoMouseCursorChange)
|
||||
return false;
|
||||
|
||||
if (imgui_cursor == ImGuiMouseCursor_None || io.MouseDrawCursor)
|
||||
{
|
||||
// Hide OS mouse cursor if imgui is drawing it or if it wants no cursor
|
||||
::SetCursor(nullptr);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Show OS mouse cursor
|
||||
LPTSTR win32_cursor = IDC_ARROW;
|
||||
switch (imgui_cursor)
|
||||
{
|
||||
case ImGuiMouseCursor_Arrow: win32_cursor = IDC_ARROW; break;
|
||||
case ImGuiMouseCursor_TextInput: win32_cursor = IDC_IBEAM; break;
|
||||
case ImGuiMouseCursor_ResizeAll: win32_cursor = IDC_SIZEALL; break;
|
||||
case ImGuiMouseCursor_ResizeEW: win32_cursor = IDC_SIZEWE; break;
|
||||
case ImGuiMouseCursor_ResizeNS: win32_cursor = IDC_SIZENS; break;
|
||||
case ImGuiMouseCursor_ResizeNESW: win32_cursor = IDC_SIZENESW; break;
|
||||
case ImGuiMouseCursor_ResizeNWSE: win32_cursor = IDC_SIZENWSE; break;
|
||||
case ImGuiMouseCursor_Hand: win32_cursor = IDC_HAND; break;
|
||||
case ImGuiMouseCursor_NotAllowed: win32_cursor = IDC_NO; break;
|
||||
}
|
||||
::SetCursor(::LoadCursor(nullptr, win32_cursor));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool IsVkDown(int vk)
|
||||
{
|
||||
return (::GetKeyState(vk) & 0x8000) != 0;
|
||||
}
|
||||
|
||||
static void ImGui_ImplWin32_AddKeyEvent(ImGuiIO& io, ImGuiKey key, bool down, int native_keycode, int native_scancode = -1)
|
||||
{
|
||||
io.AddKeyEvent(key, down);
|
||||
io.SetKeyEventNativeData(key, native_keycode, native_scancode); // To support legacy indexing (<1.87 user code)
|
||||
IM_UNUSED(native_scancode);
|
||||
}
|
||||
|
||||
static void ImGui_ImplWin32_ProcessKeyEventsWorkarounds(ImGuiIO& io)
|
||||
{
|
||||
// Left & right Shift keys: when both are pressed together, Windows tend to not generate the WM_KEYUP event for the first released one.
|
||||
if (ImGui::IsKeyDown(ImGuiKey_LeftShift) && !IsVkDown(VK_LSHIFT))
|
||||
ImGui_ImplWin32_AddKeyEvent(io, ImGuiKey_LeftShift, false, VK_LSHIFT);
|
||||
if (ImGui::IsKeyDown(ImGuiKey_RightShift) && !IsVkDown(VK_RSHIFT))
|
||||
ImGui_ImplWin32_AddKeyEvent(io, ImGuiKey_RightShift, false, VK_RSHIFT);
|
||||
|
||||
// Sometimes WM_KEYUP for Win key is not passed down to the app (e.g. for Win+V on some setups, according to GLFW).
|
||||
if (ImGui::IsKeyDown(ImGuiKey_LeftSuper) && !IsVkDown(VK_LWIN))
|
||||
ImGui_ImplWin32_AddKeyEvent(io, ImGuiKey_LeftSuper, false, VK_LWIN);
|
||||
if (ImGui::IsKeyDown(ImGuiKey_RightSuper) && !IsVkDown(VK_RWIN))
|
||||
ImGui_ImplWin32_AddKeyEvent(io, ImGuiKey_RightSuper, false, VK_RWIN);
|
||||
}
|
||||
|
||||
static void ImGui_ImplWin32_UpdateKeyModifiers(ImGuiIO& io)
|
||||
{
|
||||
io.AddKeyEvent(ImGuiMod_Ctrl, IsVkDown(VK_CONTROL));
|
||||
io.AddKeyEvent(ImGuiMod_Shift, IsVkDown(VK_SHIFT));
|
||||
io.AddKeyEvent(ImGuiMod_Alt, IsVkDown(VK_MENU));
|
||||
io.AddKeyEvent(ImGuiMod_Super, IsVkDown(VK_LWIN) || IsVkDown(VK_RWIN));
|
||||
}
|
||||
|
||||
static void ImGui_ImplWin32_UpdateMouseData(ImGuiIO& io)
|
||||
{
|
||||
ImGui_ImplWin32_Data* bd = ImGui_ImplWin32_GetBackendData(io);
|
||||
IM_ASSERT(bd->hWnd != 0);
|
||||
|
||||
HWND focused_window = ::GetForegroundWindow();
|
||||
const bool is_app_focused = (focused_window == bd->hWnd);
|
||||
if (is_app_focused)
|
||||
{
|
||||
// (Optional) Set OS mouse position from Dear ImGui if requested (rarely used, only when io.ConfigNavMoveSetMousePos is enabled by user)
|
||||
if (io.WantSetMousePos)
|
||||
{
|
||||
POINT pos = { (int)io.MousePos.x, (int)io.MousePos.y };
|
||||
if (::ClientToScreen(bd->hWnd, &pos))
|
||||
::SetCursorPos(pos.x, pos.y);
|
||||
}
|
||||
|
||||
// (Optional) Fallback to provide mouse position when focused (WM_MOUSEMOVE already provides this when hovered or captured)
|
||||
// This also fills a short gap when clicking non-client area: WM_NCMOUSELEAVE -> modal OS move -> gap -> WM_NCMOUSEMOVE
|
||||
if (!io.WantSetMousePos && bd->MouseTrackedArea == 0)
|
||||
{
|
||||
POINT pos;
|
||||
if (::GetCursorPos(&pos) && ::ScreenToClient(bd->hWnd, &pos))
|
||||
io.AddMousePosEvent((float)pos.x, (float)pos.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gamepad navigation mapping
|
||||
static void ImGui_ImplWin32_UpdateGamepads(ImGuiIO& io)
|
||||
{
|
||||
#ifndef IMGUI_IMPL_WIN32_DISABLE_GAMEPAD
|
||||
ImGui_ImplWin32_Data* bd = ImGui_ImplWin32_GetBackendData(io);
|
||||
//if ((io.ConfigFlags & ImGuiConfigFlags_NavEnableGamepad) == 0) // FIXME: Technically feeding gamepad shouldn't depend on this now that they are regular inputs.
|
||||
// return;
|
||||
|
||||
// Calling XInputGetState() every frame on disconnected gamepads is unfortunately too slow.
|
||||
// Instead we refresh gamepad availability by calling XInputGetCapabilities() _only_ after receiving WM_DEVICECHANGE.
|
||||
if (bd->WantUpdateHasGamepad)
|
||||
{
|
||||
XINPUT_CAPABILITIES caps = {};
|
||||
bd->HasGamepad = bd->XInputGetCapabilities ? (bd->XInputGetCapabilities(0, XINPUT_FLAG_GAMEPAD, &caps) == ERROR_SUCCESS) : false;
|
||||
bd->WantUpdateHasGamepad = false;
|
||||
}
|
||||
|
||||
io.BackendFlags &= ~ImGuiBackendFlags_HasGamepad;
|
||||
XINPUT_STATE xinput_state;
|
||||
XINPUT_GAMEPAD& gamepad = xinput_state.Gamepad;
|
||||
if (!bd->HasGamepad || bd->XInputGetState == nullptr || bd->XInputGetState(0, &xinput_state) != ERROR_SUCCESS)
|
||||
return;
|
||||
io.BackendFlags |= ImGuiBackendFlags_HasGamepad;
|
||||
|
||||
#define IM_SATURATE(V) (V < 0.0f ? 0.0f : V > 1.0f ? 1.0f : V)
|
||||
#define MAP_BUTTON(KEY_NO, BUTTON_ENUM) { io.AddKeyEvent(KEY_NO, (gamepad.wButtons & BUTTON_ENUM) != 0); }
|
||||
#define MAP_ANALOG(KEY_NO, VALUE, V0, V1) { float vn = (float)(VALUE - V0) / (float)(V1 - V0); io.AddKeyAnalogEvent(KEY_NO, vn > 0.10f, IM_SATURATE(vn)); }
|
||||
MAP_BUTTON(ImGuiKey_GamepadStart, XINPUT_GAMEPAD_START);
|
||||
MAP_BUTTON(ImGuiKey_GamepadBack, XINPUT_GAMEPAD_BACK);
|
||||
MAP_BUTTON(ImGuiKey_GamepadFaceLeft, XINPUT_GAMEPAD_X);
|
||||
MAP_BUTTON(ImGuiKey_GamepadFaceRight, XINPUT_GAMEPAD_B);
|
||||
MAP_BUTTON(ImGuiKey_GamepadFaceUp, XINPUT_GAMEPAD_Y);
|
||||
MAP_BUTTON(ImGuiKey_GamepadFaceDown, XINPUT_GAMEPAD_A);
|
||||
MAP_BUTTON(ImGuiKey_GamepadDpadLeft, XINPUT_GAMEPAD_DPAD_LEFT);
|
||||
MAP_BUTTON(ImGuiKey_GamepadDpadRight, XINPUT_GAMEPAD_DPAD_RIGHT);
|
||||
MAP_BUTTON(ImGuiKey_GamepadDpadUp, XINPUT_GAMEPAD_DPAD_UP);
|
||||
MAP_BUTTON(ImGuiKey_GamepadDpadDown, XINPUT_GAMEPAD_DPAD_DOWN);
|
||||
MAP_BUTTON(ImGuiKey_GamepadL1, XINPUT_GAMEPAD_LEFT_SHOULDER);
|
||||
MAP_BUTTON(ImGuiKey_GamepadR1, XINPUT_GAMEPAD_RIGHT_SHOULDER);
|
||||
MAP_ANALOG(ImGuiKey_GamepadL2, gamepad.bLeftTrigger, XINPUT_GAMEPAD_TRIGGER_THRESHOLD, 255);
|
||||
MAP_ANALOG(ImGuiKey_GamepadR2, gamepad.bRightTrigger, XINPUT_GAMEPAD_TRIGGER_THRESHOLD, 255);
|
||||
MAP_BUTTON(ImGuiKey_GamepadL3, XINPUT_GAMEPAD_LEFT_THUMB);
|
||||
MAP_BUTTON(ImGuiKey_GamepadR3, XINPUT_GAMEPAD_RIGHT_THUMB);
|
||||
MAP_ANALOG(ImGuiKey_GamepadLStickLeft, gamepad.sThumbLX, -XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE, -32768);
|
||||
MAP_ANALOG(ImGuiKey_GamepadLStickRight, gamepad.sThumbLX, +XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE, +32767);
|
||||
MAP_ANALOG(ImGuiKey_GamepadLStickUp, gamepad.sThumbLY, +XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE, +32767);
|
||||
MAP_ANALOG(ImGuiKey_GamepadLStickDown, gamepad.sThumbLY, -XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE, -32768);
|
||||
MAP_ANALOG(ImGuiKey_GamepadRStickLeft, gamepad.sThumbRX, -XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE, -32768);
|
||||
MAP_ANALOG(ImGuiKey_GamepadRStickRight, gamepad.sThumbRX, +XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE, +32767);
|
||||
MAP_ANALOG(ImGuiKey_GamepadRStickUp, gamepad.sThumbRY, +XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE, +32767);
|
||||
MAP_ANALOG(ImGuiKey_GamepadRStickDown, gamepad.sThumbRY, -XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE, -32768);
|
||||
#undef MAP_BUTTON
|
||||
#undef MAP_ANALOG
|
||||
#else // #ifndef IMGUI_IMPL_WIN32_DISABLE_GAMEPAD
|
||||
IM_UNUSED(io);
|
||||
#endif
|
||||
}
|
||||
|
||||
void ImGui_ImplWin32_NewFrame()
|
||||
{
|
||||
ImGui_ImplWin32_Data* bd = ImGui_ImplWin32_GetBackendData();
|
||||
IM_ASSERT(bd != nullptr && "Context or backend not initialized? Did you call ImGui_ImplWin32_Init()?");
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
|
||||
// Setup display size (every frame to accommodate for window resizing)
|
||||
RECT rect = { 0, 0, 0, 0 };
|
||||
::GetClientRect(bd->hWnd, &rect);
|
||||
io.DisplaySize = ImVec2((float)(rect.right - rect.left), (float)(rect.bottom - rect.top));
|
||||
|
||||
// Setup time step
|
||||
INT64 current_time = 0;
|
||||
::QueryPerformanceCounter((LARGE_INTEGER*)¤t_time);
|
||||
io.DeltaTime = (float)(current_time - bd->Time) / bd->TicksPerSecond;
|
||||
bd->Time = current_time;
|
||||
|
||||
// Update OS mouse position
|
||||
ImGui_ImplWin32_UpdateMouseData(io);
|
||||
|
||||
// Process workarounds for known Windows key handling issues
|
||||
ImGui_ImplWin32_ProcessKeyEventsWorkarounds(io);
|
||||
|
||||
// Update OS mouse cursor with the cursor requested by imgui
|
||||
ImGuiMouseCursor mouse_cursor = io.MouseDrawCursor ? ImGuiMouseCursor_None : ImGui::GetMouseCursor();
|
||||
if (bd->LastMouseCursor != mouse_cursor)
|
||||
{
|
||||
bd->LastMouseCursor = mouse_cursor;
|
||||
ImGui_ImplWin32_UpdateMouseCursor(io, mouse_cursor);
|
||||
}
|
||||
|
||||
// Update game controllers (if enabled and available)
|
||||
ImGui_ImplWin32_UpdateGamepads(io);
|
||||
}
|
||||
|
||||
// Map VK_xxx to ImGuiKey_xxx.
|
||||
// Not static to allow third-party code to use that if they want to (but undocumented)
|
||||
ImGuiKey ImGui_ImplWin32_KeyEventToImGuiKey(WPARAM wParam, LPARAM lParam);
|
||||
ImGuiKey ImGui_ImplWin32_KeyEventToImGuiKey(WPARAM wParam, LPARAM lParam)
|
||||
{
|
||||
// There is no distinct VK_xxx for keypad enter, instead it is VK_RETURN + KF_EXTENDED.
|
||||
if ((wParam == VK_RETURN) && (HIWORD(lParam) & KF_EXTENDED))
|
||||
return ImGuiKey_KeypadEnter;
|
||||
|
||||
switch (wParam)
|
||||
{
|
||||
case VK_TAB: return ImGuiKey_Tab;
|
||||
case VK_LEFT: return ImGuiKey_LeftArrow;
|
||||
case VK_RIGHT: return ImGuiKey_RightArrow;
|
||||
case VK_UP: return ImGuiKey_UpArrow;
|
||||
case VK_DOWN: return ImGuiKey_DownArrow;
|
||||
case VK_PRIOR: return ImGuiKey_PageUp;
|
||||
case VK_NEXT: return ImGuiKey_PageDown;
|
||||
case VK_HOME: return ImGuiKey_Home;
|
||||
case VK_END: return ImGuiKey_End;
|
||||
case VK_INSERT: return ImGuiKey_Insert;
|
||||
case VK_DELETE: return ImGuiKey_Delete;
|
||||
case VK_BACK: return ImGuiKey_Backspace;
|
||||
case VK_SPACE: return ImGuiKey_Space;
|
||||
case VK_RETURN: return ImGuiKey_Enter;
|
||||
case VK_ESCAPE: return ImGuiKey_Escape;
|
||||
case VK_OEM_7: return ImGuiKey_Apostrophe;
|
||||
case VK_OEM_COMMA: return ImGuiKey_Comma;
|
||||
case VK_OEM_MINUS: return ImGuiKey_Minus;
|
||||
case VK_OEM_PERIOD: return ImGuiKey_Period;
|
||||
case VK_OEM_2: return ImGuiKey_Slash;
|
||||
case VK_OEM_1: return ImGuiKey_Semicolon;
|
||||
case VK_OEM_PLUS: return ImGuiKey_Equal;
|
||||
case VK_OEM_4: return ImGuiKey_LeftBracket;
|
||||
case VK_OEM_5: return ImGuiKey_Backslash;
|
||||
case VK_OEM_6: return ImGuiKey_RightBracket;
|
||||
case VK_OEM_3: return ImGuiKey_GraveAccent;
|
||||
case VK_CAPITAL: return ImGuiKey_CapsLock;
|
||||
case VK_SCROLL: return ImGuiKey_ScrollLock;
|
||||
case VK_NUMLOCK: return ImGuiKey_NumLock;
|
||||
case VK_SNAPSHOT: return ImGuiKey_PrintScreen;
|
||||
case VK_PAUSE: return ImGuiKey_Pause;
|
||||
case VK_NUMPAD0: return ImGuiKey_Keypad0;
|
||||
case VK_NUMPAD1: return ImGuiKey_Keypad1;
|
||||
case VK_NUMPAD2: return ImGuiKey_Keypad2;
|
||||
case VK_NUMPAD3: return ImGuiKey_Keypad3;
|
||||
case VK_NUMPAD4: return ImGuiKey_Keypad4;
|
||||
case VK_NUMPAD5: return ImGuiKey_Keypad5;
|
||||
case VK_NUMPAD6: return ImGuiKey_Keypad6;
|
||||
case VK_NUMPAD7: return ImGuiKey_Keypad7;
|
||||
case VK_NUMPAD8: return ImGuiKey_Keypad8;
|
||||
case VK_NUMPAD9: return ImGuiKey_Keypad9;
|
||||
case VK_DECIMAL: return ImGuiKey_KeypadDecimal;
|
||||
case VK_DIVIDE: return ImGuiKey_KeypadDivide;
|
||||
case VK_MULTIPLY: return ImGuiKey_KeypadMultiply;
|
||||
case VK_SUBTRACT: return ImGuiKey_KeypadSubtract;
|
||||
case VK_ADD: return ImGuiKey_KeypadAdd;
|
||||
case VK_LSHIFT: return ImGuiKey_LeftShift;
|
||||
case VK_LCONTROL: return ImGuiKey_LeftCtrl;
|
||||
case VK_LMENU: return ImGuiKey_LeftAlt;
|
||||
case VK_LWIN: return ImGuiKey_LeftSuper;
|
||||
case VK_RSHIFT: return ImGuiKey_RightShift;
|
||||
case VK_RCONTROL: return ImGuiKey_RightCtrl;
|
||||
case VK_RMENU: return ImGuiKey_RightAlt;
|
||||
case VK_RWIN: return ImGuiKey_RightSuper;
|
||||
case VK_APPS: return ImGuiKey_Menu;
|
||||
case '0': return ImGuiKey_0;
|
||||
case '1': return ImGuiKey_1;
|
||||
case '2': return ImGuiKey_2;
|
||||
case '3': return ImGuiKey_3;
|
||||
case '4': return ImGuiKey_4;
|
||||
case '5': return ImGuiKey_5;
|
||||
case '6': return ImGuiKey_6;
|
||||
case '7': return ImGuiKey_7;
|
||||
case '8': return ImGuiKey_8;
|
||||
case '9': return ImGuiKey_9;
|
||||
case 'A': return ImGuiKey_A;
|
||||
case 'B': return ImGuiKey_B;
|
||||
case 'C': return ImGuiKey_C;
|
||||
case 'D': return ImGuiKey_D;
|
||||
case 'E': return ImGuiKey_E;
|
||||
case 'F': return ImGuiKey_F;
|
||||
case 'G': return ImGuiKey_G;
|
||||
case 'H': return ImGuiKey_H;
|
||||
case 'I': return ImGuiKey_I;
|
||||
case 'J': return ImGuiKey_J;
|
||||
case 'K': return ImGuiKey_K;
|
||||
case 'L': return ImGuiKey_L;
|
||||
case 'M': return ImGuiKey_M;
|
||||
case 'N': return ImGuiKey_N;
|
||||
case 'O': return ImGuiKey_O;
|
||||
case 'P': return ImGuiKey_P;
|
||||
case 'Q': return ImGuiKey_Q;
|
||||
case 'R': return ImGuiKey_R;
|
||||
case 'S': return ImGuiKey_S;
|
||||
case 'T': return ImGuiKey_T;
|
||||
case 'U': return ImGuiKey_U;
|
||||
case 'V': return ImGuiKey_V;
|
||||
case 'W': return ImGuiKey_W;
|
||||
case 'X': return ImGuiKey_X;
|
||||
case 'Y': return ImGuiKey_Y;
|
||||
case 'Z': return ImGuiKey_Z;
|
||||
case VK_F1: return ImGuiKey_F1;
|
||||
case VK_F2: return ImGuiKey_F2;
|
||||
case VK_F3: return ImGuiKey_F3;
|
||||
case VK_F4: return ImGuiKey_F4;
|
||||
case VK_F5: return ImGuiKey_F5;
|
||||
case VK_F6: return ImGuiKey_F6;
|
||||
case VK_F7: return ImGuiKey_F7;
|
||||
case VK_F8: return ImGuiKey_F8;
|
||||
case VK_F9: return ImGuiKey_F9;
|
||||
case VK_F10: return ImGuiKey_F10;
|
||||
case VK_F11: return ImGuiKey_F11;
|
||||
case VK_F12: return ImGuiKey_F12;
|
||||
case VK_F13: return ImGuiKey_F13;
|
||||
case VK_F14: return ImGuiKey_F14;
|
||||
case VK_F15: return ImGuiKey_F15;
|
||||
case VK_F16: return ImGuiKey_F16;
|
||||
case VK_F17: return ImGuiKey_F17;
|
||||
case VK_F18: return ImGuiKey_F18;
|
||||
case VK_F19: return ImGuiKey_F19;
|
||||
case VK_F20: return ImGuiKey_F20;
|
||||
case VK_F21: return ImGuiKey_F21;
|
||||
case VK_F22: return ImGuiKey_F22;
|
||||
case VK_F23: return ImGuiKey_F23;
|
||||
case VK_F24: return ImGuiKey_F24;
|
||||
case VK_BROWSER_BACK: return ImGuiKey_AppBack;
|
||||
case VK_BROWSER_FORWARD: return ImGuiKey_AppForward;
|
||||
default: return ImGuiKey_None;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow compilation with old Windows SDK. MinGW doesn't have default _WIN32_WINNT/WINVER versions.
|
||||
#ifndef WM_MOUSEHWHEEL
|
||||
#define WM_MOUSEHWHEEL 0x020E
|
||||
#endif
|
||||
#ifndef DBT_DEVNODES_CHANGED
|
||||
#define DBT_DEVNODES_CHANGED 0x0007
|
||||
#endif
|
||||
|
||||
// Helper to obtain the source of mouse messages.
|
||||
// See https://learn.microsoft.com/en-us/windows/win32/tablet/system-events-and-mouse-messages
|
||||
// Prefer to call this at the top of the message handler to avoid the possibility of other Win32 calls interfering with this.
|
||||
static ImGuiMouseSource ImGui_ImplWin32_GetMouseSourceFromMessageExtraInfo()
|
||||
{
|
||||
LPARAM extra_info = ::GetMessageExtraInfo();
|
||||
if ((extra_info & 0xFFFFFF80) == 0xFF515700)
|
||||
return ImGuiMouseSource_Pen;
|
||||
if ((extra_info & 0xFFFFFF80) == 0xFF515780)
|
||||
return ImGuiMouseSource_TouchScreen;
|
||||
return ImGuiMouseSource_Mouse;
|
||||
}
|
||||
|
||||
// Win32 message handler (process Win32 mouse/keyboard inputs, etc.)
|
||||
// Call from your application's message handler. Keep calling your message handler unless this function returns TRUE.
|
||||
// When implementing your own backend, you can read the io.WantCaptureMouse, io.WantCaptureKeyboard flags to tell if Dear ImGui wants to use your inputs.
|
||||
// - When io.WantCaptureMouse is true, do not dispatch mouse input data to your main application, or clear/overwrite your copy of the mouse data.
|
||||
// - When io.WantCaptureKeyboard is true, do not dispatch keyboard input data to your main application, or clear/overwrite your copy of the keyboard data.
|
||||
// Generally you may always pass all inputs to Dear ImGui, and hide them from your application based on those two flags.
|
||||
// PS: We treat DBLCLK messages as regular mouse down messages, so this code will work on windows classes that have the CS_DBLCLKS flag set. Our own example app code doesn't set this flag.
|
||||
|
||||
// Copy either line into your .cpp file to forward declare the function:
|
||||
extern IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandler(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam); // Use ImGui::GetCurrentContext()
|
||||
extern IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandlerEx(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam, ImGuiIO& io); // Doesn't use ImGui::GetCurrentContext()
|
||||
|
||||
IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandler(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
|
||||
{
|
||||
// Most backends don't have silent checks like this one, but we need it because WndProc are called early in CreateWindow().
|
||||
// We silently allow both context or just only backend data to be nullptr.
|
||||
if (ImGui::GetCurrentContext() == NULL)
|
||||
return 0;
|
||||
return ImGui_ImplWin32_WndProcHandlerEx(hwnd, msg, wParam, lParam, ImGui::GetIO());
|
||||
}
|
||||
|
||||
// This version is in theory thread-safe in the sense that no path should access ImGui::GetCurrentContext().
|
||||
IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandlerEx(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam, ImGuiIO& io)
|
||||
{
|
||||
ImGui_ImplWin32_Data* bd = ImGui_ImplWin32_GetBackendData(io);
|
||||
if (bd == NULL)
|
||||
return 0;
|
||||
switch (msg)
|
||||
{
|
||||
case WM_MOUSEMOVE:
|
||||
case WM_NCMOUSEMOVE:
|
||||
{
|
||||
// We need to call TrackMouseEvent in order to receive WM_MOUSELEAVE events
|
||||
ImGuiMouseSource mouse_source = ImGui_ImplWin32_GetMouseSourceFromMessageExtraInfo();
|
||||
const int area = (msg == WM_MOUSEMOVE) ? 1 : 2;
|
||||
bd->MouseHwnd = hwnd;
|
||||
if (bd->MouseTrackedArea != area)
|
||||
{
|
||||
TRACKMOUSEEVENT tme_cancel = { sizeof(tme_cancel), TME_CANCEL, hwnd, 0 };
|
||||
TRACKMOUSEEVENT tme_track = { sizeof(tme_track), (DWORD)((area == 2) ? (TME_LEAVE | TME_NONCLIENT) : TME_LEAVE), hwnd, 0 };
|
||||
if (bd->MouseTrackedArea != 0)
|
||||
::TrackMouseEvent(&tme_cancel);
|
||||
::TrackMouseEvent(&tme_track);
|
||||
bd->MouseTrackedArea = area;
|
||||
}
|
||||
POINT mouse_pos = { (LONG)GET_X_LPARAM(lParam), (LONG)GET_Y_LPARAM(lParam) };
|
||||
if (msg == WM_NCMOUSEMOVE && ::ScreenToClient(hwnd, &mouse_pos) == FALSE) // WM_NCMOUSEMOVE are provided in absolute coordinates.
|
||||
return 0;
|
||||
io.AddMouseSourceEvent(mouse_source);
|
||||
io.AddMousePosEvent((float)mouse_pos.x, (float)mouse_pos.y);
|
||||
return 0;
|
||||
}
|
||||
case WM_MOUSELEAVE:
|
||||
case WM_NCMOUSELEAVE:
|
||||
{
|
||||
const int area = (msg == WM_MOUSELEAVE) ? 1 : 2;
|
||||
if (bd->MouseTrackedArea == area)
|
||||
{
|
||||
if (bd->MouseHwnd == hwnd)
|
||||
bd->MouseHwnd = nullptr;
|
||||
bd->MouseTrackedArea = 0;
|
||||
io.AddMousePosEvent(-FLT_MAX, -FLT_MAX);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
case WM_DESTROY:
|
||||
if (bd->MouseHwnd == hwnd && bd->MouseTrackedArea != 0)
|
||||
{
|
||||
TRACKMOUSEEVENT tme_cancel = { sizeof(tme_cancel), TME_CANCEL, hwnd, 0 };
|
||||
::TrackMouseEvent(&tme_cancel);
|
||||
bd->MouseHwnd = nullptr;
|
||||
bd->MouseTrackedArea = 0;
|
||||
io.AddMousePosEvent(-FLT_MAX, -FLT_MAX);
|
||||
}
|
||||
return 0;
|
||||
case WM_LBUTTONDOWN: case WM_LBUTTONDBLCLK:
|
||||
case WM_RBUTTONDOWN: case WM_RBUTTONDBLCLK:
|
||||
case WM_MBUTTONDOWN: case WM_MBUTTONDBLCLK:
|
||||
case WM_XBUTTONDOWN: case WM_XBUTTONDBLCLK:
|
||||
{
|
||||
ImGuiMouseSource mouse_source = ImGui_ImplWin32_GetMouseSourceFromMessageExtraInfo();
|
||||
int button = 0;
|
||||
if (msg == WM_LBUTTONDOWN || msg == WM_LBUTTONDBLCLK) { button = 0; }
|
||||
if (msg == WM_RBUTTONDOWN || msg == WM_RBUTTONDBLCLK) { button = 1; }
|
||||
if (msg == WM_MBUTTONDOWN || msg == WM_MBUTTONDBLCLK) { button = 2; }
|
||||
if (msg == WM_XBUTTONDOWN || msg == WM_XBUTTONDBLCLK) { button = (GET_XBUTTON_WPARAM(wParam) == XBUTTON1) ? 3 : 4; }
|
||||
if (bd->MouseButtonsDown == 0 && ::GetCapture() == nullptr)
|
||||
::SetCapture(hwnd); // Allow us to read mouse coordinates when dragging mouse outside of our window bounds.
|
||||
bd->MouseButtonsDown |= 1 << button;
|
||||
io.AddMouseSourceEvent(mouse_source);
|
||||
io.AddMouseButtonEvent(button, true);
|
||||
return 0;
|
||||
}
|
||||
case WM_LBUTTONUP:
|
||||
case WM_RBUTTONUP:
|
||||
case WM_MBUTTONUP:
|
||||
case WM_XBUTTONUP:
|
||||
{
|
||||
ImGuiMouseSource mouse_source = ImGui_ImplWin32_GetMouseSourceFromMessageExtraInfo();
|
||||
int button = 0;
|
||||
if (msg == WM_LBUTTONUP) { button = 0; }
|
||||
if (msg == WM_RBUTTONUP) { button = 1; }
|
||||
if (msg == WM_MBUTTONUP) { button = 2; }
|
||||
if (msg == WM_XBUTTONUP) { button = (GET_XBUTTON_WPARAM(wParam) == XBUTTON1) ? 3 : 4; }
|
||||
bd->MouseButtonsDown &= ~(1 << button);
|
||||
if (bd->MouseButtonsDown == 0 && ::GetCapture() == hwnd)
|
||||
::ReleaseCapture();
|
||||
io.AddMouseSourceEvent(mouse_source);
|
||||
io.AddMouseButtonEvent(button, false);
|
||||
return 0;
|
||||
}
|
||||
case WM_MOUSEWHEEL:
|
||||
io.AddMouseWheelEvent(0.0f, (float)GET_WHEEL_DELTA_WPARAM(wParam) / (float)WHEEL_DELTA);
|
||||
return 0;
|
||||
case WM_MOUSEHWHEEL:
|
||||
io.AddMouseWheelEvent(-(float)GET_WHEEL_DELTA_WPARAM(wParam) / (float)WHEEL_DELTA, 0.0f);
|
||||
return 0;
|
||||
case WM_KEYDOWN:
|
||||
case WM_KEYUP:
|
||||
case WM_SYSKEYDOWN:
|
||||
case WM_SYSKEYUP:
|
||||
{
|
||||
const bool is_key_down = (msg == WM_KEYDOWN || msg == WM_SYSKEYDOWN);
|
||||
if (wParam < 256)
|
||||
{
|
||||
// Submit modifiers
|
||||
ImGui_ImplWin32_UpdateKeyModifiers(io);
|
||||
|
||||
// Obtain virtual key code and convert to ImGuiKey
|
||||
const ImGuiKey key = ImGui_ImplWin32_KeyEventToImGuiKey(wParam, lParam);
|
||||
const int vk = (int)wParam;
|
||||
const int scancode = (int)LOBYTE(HIWORD(lParam));
|
||||
|
||||
// Special behavior for VK_SNAPSHOT / ImGuiKey_PrintScreen as Windows doesn't emit the key down event.
|
||||
if (key == ImGuiKey_PrintScreen && !is_key_down)
|
||||
ImGui_ImplWin32_AddKeyEvent(io, key, true, vk, scancode);
|
||||
|
||||
// Submit key event
|
||||
if (key != ImGuiKey_None)
|
||||
ImGui_ImplWin32_AddKeyEvent(io, key, is_key_down, vk, scancode);
|
||||
|
||||
// Submit individual left/right modifier events
|
||||
if (vk == VK_SHIFT)
|
||||
{
|
||||
// Important: Shift keys tend to get stuck when pressed together, missing key-up events are corrected in ImGui_ImplWin32_ProcessKeyEventsWorkarounds()
|
||||
if (IsVkDown(VK_LSHIFT) == is_key_down) { ImGui_ImplWin32_AddKeyEvent(io, ImGuiKey_LeftShift, is_key_down, VK_LSHIFT, scancode); }
|
||||
if (IsVkDown(VK_RSHIFT) == is_key_down) { ImGui_ImplWin32_AddKeyEvent(io, ImGuiKey_RightShift, is_key_down, VK_RSHIFT, scancode); }
|
||||
}
|
||||
else if (vk == VK_CONTROL)
|
||||
{
|
||||
if (IsVkDown(VK_LCONTROL) == is_key_down) { ImGui_ImplWin32_AddKeyEvent(io, ImGuiKey_LeftCtrl, is_key_down, VK_LCONTROL, scancode); }
|
||||
if (IsVkDown(VK_RCONTROL) == is_key_down) { ImGui_ImplWin32_AddKeyEvent(io, ImGuiKey_RightCtrl, is_key_down, VK_RCONTROL, scancode); }
|
||||
}
|
||||
else if (vk == VK_MENU)
|
||||
{
|
||||
if (IsVkDown(VK_LMENU) == is_key_down) { ImGui_ImplWin32_AddKeyEvent(io, ImGuiKey_LeftAlt, is_key_down, VK_LMENU, scancode); }
|
||||
if (IsVkDown(VK_RMENU) == is_key_down) { ImGui_ImplWin32_AddKeyEvent(io, ImGuiKey_RightAlt, is_key_down, VK_RMENU, scancode); }
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
case WM_SETFOCUS:
|
||||
case WM_KILLFOCUS:
|
||||
io.AddFocusEvent(msg == WM_SETFOCUS);
|
||||
return 0;
|
||||
case WM_INPUTLANGCHANGE:
|
||||
ImGui_ImplWin32_UpdateKeyboardCodePage(io);
|
||||
return 0;
|
||||
case WM_CHAR:
|
||||
if (::IsWindowUnicode(hwnd))
|
||||
{
|
||||
// You can also use ToAscii()+GetKeyboardState() to retrieve characters.
|
||||
if (wParam > 0 && wParam < 0x10000)
|
||||
io.AddInputCharacterUTF16((unsigned short)wParam);
|
||||
}
|
||||
else
|
||||
{
|
||||
wchar_t wch = 0;
|
||||
::MultiByteToWideChar(bd->KeyboardCodePage, MB_PRECOMPOSED, (char*)&wParam, 1, &wch, 1);
|
||||
io.AddInputCharacter(wch);
|
||||
}
|
||||
return 0;
|
||||
case WM_SETCURSOR:
|
||||
// This is required to restore cursor when transitioning from e.g resize borders to client area.
|
||||
if (LOWORD(lParam) == HTCLIENT && ImGui_ImplWin32_UpdateMouseCursor(io, bd->LastMouseCursor))
|
||||
return 1;
|
||||
return 0;
|
||||
case WM_DEVICECHANGE:
|
||||
#ifndef IMGUI_IMPL_WIN32_DISABLE_GAMEPAD
|
||||
if ((UINT)wParam == DBT_DEVNODES_CHANGED)
|
||||
bd->WantUpdateHasGamepad = true;
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
//--------------------------------------------------------------------------------------------------------
|
||||
// DPI-related helpers (optional)
|
||||
//--------------------------------------------------------------------------------------------------------
|
||||
// - Use to enable DPI awareness without having to create an application manifest.
|
||||
// - Your own app may already do this via a manifest or explicit calls. This is mostly useful for our examples/ apps.
|
||||
// - In theory we could call simple functions from Windows SDK such as SetProcessDPIAware(), SetProcessDpiAwareness(), etc.
|
||||
// but most of the functions provided by Microsoft require Windows 8.1/10+ SDK at compile time and Windows 8/10+ at runtime,
|
||||
// neither we want to require the user to have. So we dynamically select and load those functions to avoid dependencies.
|
||||
//---------------------------------------------------------------------------------------------------------
|
||||
// This is the scheme successfully used by GLFW (from which we borrowed some of the code) and other apps aiming to be highly portable.
|
||||
// ImGui_ImplWin32_EnableDpiAwareness() is just a helper called by main.cpp, we don't call it automatically.
|
||||
// If you are trying to implement your own backend for your own engine, you may ignore that noise.
|
||||
//---------------------------------------------------------------------------------------------------------
|
||||
|
||||
// Perform our own check with RtlVerifyVersionInfo() instead of using functions from <VersionHelpers.h> as they
|
||||
// require a manifest to be functional for checks above 8.1. See https://github.com/ocornut/imgui/issues/4200
|
||||
static BOOL _IsWindowsVersionOrGreater(WORD major, WORD minor, WORD)
|
||||
{
|
||||
typedef LONG(WINAPI* PFN_RtlVerifyVersionInfo)(OSVERSIONINFOEXW*, ULONG, ULONGLONG);
|
||||
static PFN_RtlVerifyVersionInfo RtlVerifyVersionInfoFn = nullptr;
|
||||
if (RtlVerifyVersionInfoFn == nullptr)
|
||||
if (HMODULE ntdllModule = ::GetModuleHandleA("ntdll.dll"))
|
||||
RtlVerifyVersionInfoFn = (PFN_RtlVerifyVersionInfo)GetProcAddress(ntdllModule, "RtlVerifyVersionInfo");
|
||||
if (RtlVerifyVersionInfoFn == nullptr)
|
||||
return FALSE;
|
||||
|
||||
RTL_OSVERSIONINFOEXW versionInfo = { };
|
||||
ULONGLONG conditionMask = 0;
|
||||
versionInfo.dwOSVersionInfoSize = sizeof(RTL_OSVERSIONINFOEXW);
|
||||
versionInfo.dwMajorVersion = major;
|
||||
versionInfo.dwMinorVersion = minor;
|
||||
VER_SET_CONDITION(conditionMask, VER_MAJORVERSION, VER_GREATER_EQUAL);
|
||||
VER_SET_CONDITION(conditionMask, VER_MINORVERSION, VER_GREATER_EQUAL);
|
||||
return (RtlVerifyVersionInfoFn(&versionInfo, VER_MAJORVERSION | VER_MINORVERSION, conditionMask) == 0) ? TRUE : FALSE;
|
||||
}
|
||||
|
||||
#define _IsWindowsVistaOrGreater() _IsWindowsVersionOrGreater(HIBYTE(0x0600), LOBYTE(0x0600), 0) // _WIN32_WINNT_VISTA
|
||||
#define _IsWindows8OrGreater() _IsWindowsVersionOrGreater(HIBYTE(0x0602), LOBYTE(0x0602), 0) // _WIN32_WINNT_WIN8
|
||||
#define _IsWindows8Point1OrGreater() _IsWindowsVersionOrGreater(HIBYTE(0x0603), LOBYTE(0x0603), 0) // _WIN32_WINNT_WINBLUE
|
||||
#define _IsWindows10OrGreater() _IsWindowsVersionOrGreater(HIBYTE(0x0A00), LOBYTE(0x0A00), 0) // _WIN32_WINNT_WINTHRESHOLD / _WIN32_WINNT_WIN10
|
||||
|
||||
#ifndef DPI_ENUMS_DECLARED
|
||||
typedef enum { PROCESS_DPI_UNAWARE = 0, PROCESS_SYSTEM_DPI_AWARE = 1, PROCESS_PER_MONITOR_DPI_AWARE = 2 } PROCESS_DPI_AWARENESS;
|
||||
typedef enum { MDT_EFFECTIVE_DPI = 0, MDT_ANGULAR_DPI = 1, MDT_RAW_DPI = 2, MDT_DEFAULT = MDT_EFFECTIVE_DPI } MONITOR_DPI_TYPE;
|
||||
#endif
|
||||
#ifndef _DPI_AWARENESS_CONTEXTS_
|
||||
DECLARE_HANDLE(DPI_AWARENESS_CONTEXT);
|
||||
#define DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE (DPI_AWARENESS_CONTEXT)-3
|
||||
#endif
|
||||
#ifndef DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2
|
||||
#define DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 (DPI_AWARENESS_CONTEXT)-4
|
||||
#endif
|
||||
typedef HRESULT(WINAPI* PFN_SetProcessDpiAwareness)(PROCESS_DPI_AWARENESS); // Shcore.lib + dll, Windows 8.1+
|
||||
typedef HRESULT(WINAPI* PFN_GetDpiForMonitor)(HMONITOR, MONITOR_DPI_TYPE, UINT*, UINT*); // Shcore.lib + dll, Windows 8.1+
|
||||
typedef DPI_AWARENESS_CONTEXT(WINAPI* PFN_SetThreadDpiAwarenessContext)(DPI_AWARENESS_CONTEXT); // User32.lib + dll, Windows 10 v1607+ (Creators Update)
|
||||
|
||||
// Helper function to enable DPI awareness without setting up a manifest
|
||||
void ImGui_ImplWin32_EnableDpiAwareness()
|
||||
{
|
||||
if (_IsWindows10OrGreater())
|
||||
{
|
||||
static HINSTANCE user32_dll = ::LoadLibraryA("user32.dll"); // Reference counted per-process
|
||||
if (PFN_SetThreadDpiAwarenessContext SetThreadDpiAwarenessContextFn = (PFN_SetThreadDpiAwarenessContext)::GetProcAddress(user32_dll, "SetThreadDpiAwarenessContext"))
|
||||
{
|
||||
SetThreadDpiAwarenessContextFn(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (_IsWindows8Point1OrGreater())
|
||||
{
|
||||
static HINSTANCE shcore_dll = ::LoadLibraryA("shcore.dll"); // Reference counted per-process
|
||||
if (PFN_SetProcessDpiAwareness SetProcessDpiAwarenessFn = (PFN_SetProcessDpiAwareness)::GetProcAddress(shcore_dll, "SetProcessDpiAwareness"))
|
||||
{
|
||||
SetProcessDpiAwarenessFn(PROCESS_PER_MONITOR_DPI_AWARE);
|
||||
return;
|
||||
}
|
||||
}
|
||||
#if _WIN32_WINNT >= 0x0600
|
||||
::SetProcessDPIAware();
|
||||
#endif
|
||||
}
|
||||
|
||||
#if defined(_MSC_VER) && !defined(NOGDI)
|
||||
#pragma comment(lib, "gdi32") // Link with gdi32.lib for GetDeviceCaps(). MinGW will require linking with '-lgdi32'
|
||||
#endif
|
||||
|
||||
float ImGui_ImplWin32_GetDpiScaleForMonitor(void* monitor)
|
||||
{
|
||||
UINT xdpi = 96, ydpi = 96;
|
||||
if (_IsWindows8Point1OrGreater())
|
||||
{
|
||||
static HINSTANCE shcore_dll = ::LoadLibraryA("shcore.dll"); // Reference counted per-process
|
||||
static PFN_GetDpiForMonitor GetDpiForMonitorFn = nullptr;
|
||||
if (GetDpiForMonitorFn == nullptr && shcore_dll != nullptr)
|
||||
GetDpiForMonitorFn = (PFN_GetDpiForMonitor)::GetProcAddress(shcore_dll, "GetDpiForMonitor");
|
||||
if (GetDpiForMonitorFn != nullptr)
|
||||
{
|
||||
GetDpiForMonitorFn((HMONITOR)monitor, MDT_EFFECTIVE_DPI, &xdpi, &ydpi);
|
||||
IM_ASSERT(xdpi == ydpi); // Please contact me if you hit this assert!
|
||||
return xdpi / 96.0f;
|
||||
}
|
||||
}
|
||||
#ifndef NOGDI
|
||||
const HDC dc = ::GetDC(nullptr);
|
||||
xdpi = ::GetDeviceCaps(dc, LOGPIXELSX);
|
||||
ydpi = ::GetDeviceCaps(dc, LOGPIXELSY);
|
||||
IM_ASSERT(xdpi == ydpi); // Please contact me if you hit this assert!
|
||||
::ReleaseDC(nullptr, dc);
|
||||
#endif
|
||||
return xdpi / 96.0f;
|
||||
}
|
||||
|
||||
float ImGui_ImplWin32_GetDpiScaleForHwnd(void* hwnd)
|
||||
{
|
||||
HMONITOR monitor = ::MonitorFromWindow((HWND)hwnd, MONITOR_DEFAULTTONEAREST);
|
||||
return ImGui_ImplWin32_GetDpiScaleForMonitor(monitor);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------------------------------------------
|
||||
// Transparency related helpers (optional)
|
||||
//--------------------------------------------------------------------------------------------------------
|
||||
|
||||
#if defined(_MSC_VER)
|
||||
#pragma comment(lib, "dwmapi") // Link with dwmapi.lib. MinGW will require linking with '-ldwmapi'
|
||||
#endif
|
||||
|
||||
// [experimental]
|
||||
// Borrowed from GLFW's function updateFramebufferTransparency() in src/win32_window.c
|
||||
// (the Dwm* functions are Vista era functions but we are borrowing logic from GLFW)
|
||||
void ImGui_ImplWin32_EnableAlphaCompositing(void* hwnd)
|
||||
{
|
||||
if (!_IsWindowsVistaOrGreater())
|
||||
return;
|
||||
|
||||
BOOL composition;
|
||||
if (FAILED(::DwmIsCompositionEnabled(&composition)) || !composition)
|
||||
return;
|
||||
|
||||
BOOL opaque;
|
||||
DWORD color;
|
||||
if (_IsWindows8OrGreater() || (SUCCEEDED(::DwmGetColorizationColor(&color, &opaque)) && !opaque))
|
||||
{
|
||||
HRGN region = ::CreateRectRgn(0, 0, -1, -1);
|
||||
DWM_BLURBEHIND bb = {};
|
||||
bb.dwFlags = DWM_BB_ENABLE | DWM_BB_BLURREGION;
|
||||
bb.hRgnBlur = region;
|
||||
bb.fEnable = TRUE;
|
||||
::DwmEnableBlurBehindWindow((HWND)hwnd, &bb);
|
||||
::DeleteObject(region);
|
||||
}
|
||||
else
|
||||
{
|
||||
DWM_BLURBEHIND bb = {};
|
||||
bb.dwFlags = DWM_BB_ENABLE;
|
||||
::DwmEnableBlurBehindWindow((HWND)hwnd, &bb);
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------------------------------------------
|
||||
|
||||
#if defined(__GNUC__)
|
||||
#pragma GCC diagnostic pop
|
||||
#endif
|
||||
#if defined(__clang__)
|
||||
#pragma clang diagnostic pop
|
||||
#endif
|
||||
|
||||
#endif // #ifndef IMGUI_DISABLE
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
// dear imgui: Platform Backend for Windows (standard windows API for 32-bits AND 64-bits applications)
|
||||
// This needs to be used along with a Renderer (e.g. DirectX11, OpenGL3, Vulkan..)
|
||||
|
||||
// Implemented features:
|
||||
// [X] Platform: Clipboard support (for Win32 this is actually part of core dear imgui)
|
||||
// [X] Platform: Mouse support. Can discriminate Mouse/TouchScreen/Pen.
|
||||
// [X] Platform: Keyboard support. Since 1.87 we are using the io.AddKeyEvent() function. Pass ImGuiKey values to all key functions e.g. ImGui::IsKeyPressed(ImGuiKey_Space). [Legacy VK_* values are obsolete since 1.87 and not supported since 1.91.5]
|
||||
// [X] Platform: Gamepad support. Enabled with 'io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad'.
|
||||
// [X] Platform: Mouse cursor shape and visibility. Disable with 'io.ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange'.
|
||||
|
||||
// You can use unmodified imgui_impl_* files in your project. See examples/ folder for examples of using this.
|
||||
// Prefer including the entire imgui/ repository into your project (either as a copy or as a submodule), and only build the backends you need.
|
||||
// Learn about Dear ImGui:
|
||||
// - FAQ https://dearimgui.com/faq
|
||||
// - Getting Started https://dearimgui.com/getting-started
|
||||
// - Documentation https://dearimgui.com/docs (same as your local docs/ folder).
|
||||
// - Introduction, links and more at the top of imgui.cpp
|
||||
|
||||
#pragma once
|
||||
#include "imgui.h" // IMGUI_IMPL_API
|
||||
#ifndef IMGUI_DISABLE
|
||||
|
||||
// Follow "Getting Started" link and check examples/ folder to learn about using backends!
|
||||
IMGUI_IMPL_API bool ImGui_ImplWin32_Init(void* hwnd);
|
||||
IMGUI_IMPL_API bool ImGui_ImplWin32_InitForOpenGL(void* hwnd);
|
||||
IMGUI_IMPL_API void ImGui_ImplWin32_Shutdown();
|
||||
IMGUI_IMPL_API void ImGui_ImplWin32_NewFrame();
|
||||
|
||||
// Win32 message handler your application need to call.
|
||||
// - Intentionally commented out in a '#if 0' block to avoid dragging dependencies on <windows.h> from this helper.
|
||||
// - You should COPY the line below into your .cpp code to forward declare the function and then you can call it.
|
||||
// - Call from your application's message handler. Keep calling your message handler unless this function returns TRUE.
|
||||
|
||||
#if 0
|
||||
extern IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandler(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam);
|
||||
#endif
|
||||
|
||||
// DPI-related helpers (optional)
|
||||
// - Use to enable DPI awareness without having to create an application manifest.
|
||||
// - Your own app may already do this via a manifest or explicit calls. This is mostly useful for our examples/ apps.
|
||||
// - In theory we could call simple functions from Windows SDK such as SetProcessDPIAware(), SetProcessDpiAwareness(), etc.
|
||||
// but most of the functions provided by Microsoft require Windows 8.1/10+ SDK at compile time and Windows 8/10+ at runtime,
|
||||
// neither we want to require the user to have. So we dynamically select and load those functions to avoid dependencies.
|
||||
IMGUI_IMPL_API void ImGui_ImplWin32_EnableDpiAwareness();
|
||||
IMGUI_IMPL_API float ImGui_ImplWin32_GetDpiScaleForHwnd(void* hwnd); // HWND hwnd
|
||||
IMGUI_IMPL_API float ImGui_ImplWin32_GetDpiScaleForMonitor(void* monitor); // HMONITOR monitor
|
||||
|
||||
// Transparency related helpers (optional) [experimental]
|
||||
// - Use to enable alpha compositing transparency with the desktop.
|
||||
// - Use together with e.g. clearing your framebuffer with zero-alpha.
|
||||
IMGUI_IMPL_API void ImGui_ImplWin32_EnableAlphaCompositing(void* hwnd); // HWND hwnd
|
||||
|
||||
#endif // #ifndef IMGUI_DISABLE
|
||||
+3578
File diff suppressed because it is too large.
Load diff
+4467
File diff suppressed because it is too large.
Load diff
+10310
File diff suppressed because it is too large.
Load diff
+627
@@ -0,0 +1,627 @@
|
||||
// [DEAR IMGUI]
|
||||
// This is a slightly modified version of stb_rect_pack.h 1.01.
|
||||
// Grep for [DEAR IMGUI] to find the changes.
|
||||
//
|
||||
// stb_rect_pack.h - v1.01 - public domain - rectangle packing
|
||||
// Sean Barrett 2014
|
||||
//
|
||||
// Useful for e.g. packing rectangular textures into an atlas.
|
||||
// Does not do rotation.
|
||||
//
|
||||
// Before #including,
|
||||
//
|
||||
// #define STB_RECT_PACK_IMPLEMENTATION
|
||||
//
|
||||
// in the file that you want to have the implementation.
|
||||
//
|
||||
// Not necessarily the awesomest packing method, but better than
|
||||
// the totally naive one in stb_truetype (which is primarily what
|
||||
// this is meant to replace).
|
||||
//
|
||||
// Has only had a few tests run, may have issues.
|
||||
//
|
||||
// More docs to come.
|
||||
//
|
||||
// No memory allocations; uses qsort() and assert() from stdlib.
|
||||
// Can override those by defining STBRP_SORT and STBRP_ASSERT.
|
||||
//
|
||||
// This library currently uses the Skyline Bottom-Left algorithm.
|
||||
//
|
||||
// Please note: better rectangle packers are welcome! Please
|
||||
// implement them to the same API, but with a different init
|
||||
// function.
|
||||
//
|
||||
// Credits
|
||||
//
|
||||
// Library
|
||||
// Sean Barrett
|
||||
// Minor features
|
||||
// Martins Mozeiko
|
||||
// github:IntellectualKitty
|
||||
//
|
||||
// Bugfixes / warning fixes
|
||||
// Jeremy Jaussaud
|
||||
// Fabian Giesen
|
||||
//
|
||||
// Version history:
|
||||
//
|
||||
// 1.01 (2021-07-11) always use large rect mode, expose STBRP__MAXVAL in public section
|
||||
// 1.00 (2019-02-25) avoid small space waste; gracefully fail too-wide rectangles
|
||||
// 0.99 (2019-02-07) warning fixes
|
||||
// 0.11 (2017-03-03) return packing success/fail result
|
||||
// 0.10 (2016-10-25) remove cast-away-const to avoid warnings
|
||||
// 0.09 (2016-08-27) fix compiler warnings
|
||||
// 0.08 (2015-09-13) really fix bug with empty rects (w=0 or h=0)
|
||||
// 0.07 (2015-09-13) fix bug with empty rects (w=0 or h=0)
|
||||
// 0.06 (2015-04-15) added STBRP_SORT to allow replacing qsort
|
||||
// 0.05: added STBRP_ASSERT to allow replacing assert
|
||||
// 0.04: fixed minor bug in STBRP_LARGE_RECTS support
|
||||
// 0.01: initial release
|
||||
//
|
||||
// LICENSE
|
||||
//
|
||||
// See end of file for license information.
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// INCLUDE SECTION
|
||||
//
|
||||
|
||||
#ifndef STB_INCLUDE_STB_RECT_PACK_H
|
||||
#define STB_INCLUDE_STB_RECT_PACK_H
|
||||
|
||||
#define STB_RECT_PACK_VERSION 1
|
||||
|
||||
#ifdef STBRP_STATIC
|
||||
#define STBRP_DEF static
|
||||
#else
|
||||
#define STBRP_DEF extern
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct stbrp_context stbrp_context;
|
||||
typedef struct stbrp_node stbrp_node;
|
||||
typedef struct stbrp_rect stbrp_rect;
|
||||
|
||||
typedef int stbrp_coord;
|
||||
|
||||
#define STBRP__MAXVAL 0x7fffffff
|
||||
// Mostly for internal use, but this is the maximum supported coordinate value.
|
||||
|
||||
STBRP_DEF int stbrp_pack_rects (stbrp_context *context, stbrp_rect *rects, int num_rects);
|
||||
// Assign packed locations to rectangles. The rectangles are of type
|
||||
// 'stbrp_rect' defined below, stored in the array 'rects', and there
|
||||
// are 'num_rects' many of them.
|
||||
//
|
||||
// Rectangles which are successfully packed have the 'was_packed' flag
|
||||
// set to a non-zero value and 'x' and 'y' store the minimum location
|
||||
// on each axis (i.e. bottom-left in cartesian coordinates, top-left
|
||||
// if you imagine y increasing downwards). Rectangles which do not fit
|
||||
// have the 'was_packed' flag set to 0.
|
||||
//
|
||||
// You should not try to access the 'rects' array from another thread
|
||||
// while this function is running, as the function temporarily reorders
|
||||
// the array while it executes.
|
||||
//
|
||||
// To pack into another rectangle, you need to call stbrp_init_target
|
||||
// again. To continue packing into the same rectangle, you can call
|
||||
// this function again. Calling this multiple times with multiple rect
|
||||
// arrays will probably produce worse packing results than calling it
|
||||
// a single time with the full rectangle array, but the option is
|
||||
// available.
|
||||
//
|
||||
// The function returns 1 if all of the rectangles were successfully
|
||||
// packed and 0 otherwise.
|
||||
|
||||
struct stbrp_rect
|
||||
{
|
||||
// reserved for your use:
|
||||
int id;
|
||||
|
||||
// input:
|
||||
stbrp_coord w, h;
|
||||
|
||||
// output:
|
||||
stbrp_coord x, y;
|
||||
int was_packed; // non-zero if valid packing
|
||||
|
||||
}; // 16 bytes, nominally
|
||||
|
||||
|
||||
STBRP_DEF void stbrp_init_target (stbrp_context *context, int width, int height, stbrp_node *nodes, int num_nodes);
|
||||
// Initialize a rectangle packer to:
|
||||
// pack a rectangle that is 'width' by 'height' in dimensions
|
||||
// using temporary storage provided by the array 'nodes', which is 'num_nodes' long
|
||||
//
|
||||
// You must call this function every time you start packing into a new target.
|
||||
//
|
||||
// There is no "shutdown" function. The 'nodes' memory must stay valid for
|
||||
// the following stbrp_pack_rects() call (or calls), but can be freed after
|
||||
// the call (or calls) finish.
|
||||
//
|
||||
// Note: to guarantee best results, either:
|
||||
// 1. make sure 'num_nodes' >= 'width'
|
||||
// or 2. call stbrp_allow_out_of_mem() defined below with 'allow_out_of_mem = 1'
|
||||
//
|
||||
// If you don't do either of the above things, widths will be quantized to multiples
|
||||
// of small integers to guarantee the algorithm doesn't run out of temporary storage.
|
||||
//
|
||||
// If you do #2, then the non-quantized algorithm will be used, but the algorithm
|
||||
// may run out of temporary storage and be unable to pack some rectangles.
|
||||
|
||||
STBRP_DEF void stbrp_setup_allow_out_of_mem (stbrp_context *context, int allow_out_of_mem);
|
||||
// Optionally call this function after init but before doing any packing to
|
||||
// change the handling of the out-of-temp-memory scenario, described above.
|
||||
// If you call init again, this will be reset to the default (false).
|
||||
|
||||
|
||||
STBRP_DEF void stbrp_setup_heuristic (stbrp_context *context, int heuristic);
|
||||
// Optionally select which packing heuristic the library should use. Different
|
||||
// heuristics will produce better/worse results for different data sets.
|
||||
// If you call init again, this will be reset to the default.
|
||||
|
||||
enum
|
||||
{
|
||||
STBRP_HEURISTIC_Skyline_default=0,
|
||||
STBRP_HEURISTIC_Skyline_BL_sortHeight = STBRP_HEURISTIC_Skyline_default,
|
||||
STBRP_HEURISTIC_Skyline_BF_sortHeight
|
||||
};
|
||||
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// the details of the following structures don't matter to you, but they must
|
||||
// be visible so you can handle the memory allocations for them
|
||||
|
||||
struct stbrp_node
|
||||
{
|
||||
stbrp_coord x,y;
|
||||
stbrp_node *next;
|
||||
};
|
||||
|
||||
struct stbrp_context
|
||||
{
|
||||
int width;
|
||||
int height;
|
||||
int align;
|
||||
int init_mode;
|
||||
int heuristic;
|
||||
int num_nodes;
|
||||
stbrp_node *active_head;
|
||||
stbrp_node *free_head;
|
||||
stbrp_node extra[2]; // we allocate two extra nodes so optimal user-node-count is 'width' not 'width+2'
|
||||
};
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// IMPLEMENTATION SECTION
|
||||
//
|
||||
|
||||
#ifdef STB_RECT_PACK_IMPLEMENTATION
|
||||
#ifndef STBRP_SORT
|
||||
#include <stdlib.h>
|
||||
#define STBRP_SORT qsort
|
||||
#endif
|
||||
|
||||
#ifndef STBRP_ASSERT
|
||||
#include <assert.h>
|
||||
#define STBRP_ASSERT assert
|
||||
#endif
|
||||
|
||||
#ifdef _MSC_VER
|
||||
#define STBRP__NOTUSED(v) (void)(v)
|
||||
#define STBRP__CDECL __cdecl
|
||||
#else
|
||||
#define STBRP__NOTUSED(v) (void)sizeof(v)
|
||||
#define STBRP__CDECL
|
||||
#endif
|
||||
|
||||
enum
|
||||
{
|
||||
STBRP__INIT_skyline = 1
|
||||
};
|
||||
|
||||
STBRP_DEF void stbrp_setup_heuristic(stbrp_context *context, int heuristic)
|
||||
{
|
||||
switch (context->init_mode) {
|
||||
case STBRP__INIT_skyline:
|
||||
STBRP_ASSERT(heuristic == STBRP_HEURISTIC_Skyline_BL_sortHeight || heuristic == STBRP_HEURISTIC_Skyline_BF_sortHeight);
|
||||
context->heuristic = heuristic;
|
||||
break;
|
||||
default:
|
||||
STBRP_ASSERT(0);
|
||||
}
|
||||
}
|
||||
|
||||
STBRP_DEF void stbrp_setup_allow_out_of_mem(stbrp_context *context, int allow_out_of_mem)
|
||||
{
|
||||
if (allow_out_of_mem)
|
||||
// if it's ok to run out of memory, then don't bother aligning them;
|
||||
// this gives better packing, but may fail due to OOM (even though
|
||||
// the rectangles easily fit). @TODO a smarter approach would be to only
|
||||
// quantize once we've hit OOM, then we could get rid of this parameter.
|
||||
context->align = 1;
|
||||
else {
|
||||
// if it's not ok to run out of memory, then quantize the widths
|
||||
// so that num_nodes is always enough nodes.
|
||||
//
|
||||
// I.e. num_nodes * align >= width
|
||||
// align >= width / num_nodes
|
||||
// align = ceil(width/num_nodes)
|
||||
|
||||
context->align = (context->width + context->num_nodes-1) / context->num_nodes;
|
||||
}
|
||||
}
|
||||
|
||||
STBRP_DEF void stbrp_init_target(stbrp_context *context, int width, int height, stbrp_node *nodes, int num_nodes)
|
||||
{
|
||||
int i;
|
||||
|
||||
for (i=0; i < num_nodes-1; ++i)
|
||||
nodes[i].next = &nodes[i+1];
|
||||
nodes[i].next = NULL;
|
||||
context->init_mode = STBRP__INIT_skyline;
|
||||
context->heuristic = STBRP_HEURISTIC_Skyline_default;
|
||||
context->free_head = &nodes[0];
|
||||
context->active_head = &context->extra[0];
|
||||
context->width = width;
|
||||
context->height = height;
|
||||
context->num_nodes = num_nodes;
|
||||
stbrp_setup_allow_out_of_mem(context, 0);
|
||||
|
||||
// node 0 is the full width, node 1 is the sentinel (lets us not store width explicitly)
|
||||
context->extra[0].x = 0;
|
||||
context->extra[0].y = 0;
|
||||
context->extra[0].next = &context->extra[1];
|
||||
context->extra[1].x = (stbrp_coord) width;
|
||||
context->extra[1].y = (1<<30);
|
||||
context->extra[1].next = NULL;
|
||||
}
|
||||
|
||||
// find minimum y position if it starts at x1
|
||||
static int stbrp__skyline_find_min_y(stbrp_context *c, stbrp_node *first, int x0, int width, int *pwaste)
|
||||
{
|
||||
stbrp_node *node = first;
|
||||
int x1 = x0 + width;
|
||||
int min_y, visited_width, waste_area;
|
||||
|
||||
STBRP__NOTUSED(c);
|
||||
|
||||
STBRP_ASSERT(first->x <= x0);
|
||||
|
||||
#if 0
|
||||
// skip in case we're past the node
|
||||
while (node->next->x <= x0)
|
||||
++node;
|
||||
#else
|
||||
STBRP_ASSERT(node->next->x > x0); // we ended up handling this in the caller for efficiency
|
||||
#endif
|
||||
|
||||
STBRP_ASSERT(node->x <= x0);
|
||||
|
||||
min_y = 0;
|
||||
waste_area = 0;
|
||||
visited_width = 0;
|
||||
while (node->x < x1) {
|
||||
if (node->y > min_y) {
|
||||
// raise min_y higher.
|
||||
// we've accounted for all waste up to min_y,
|
||||
// but we'll now add more waste for everything we've visted
|
||||
waste_area += visited_width * (node->y - min_y);
|
||||
min_y = node->y;
|
||||
// the first time through, visited_width might be reduced
|
||||
if (node->x < x0)
|
||||
visited_width += node->next->x - x0;
|
||||
else
|
||||
visited_width += node->next->x - node->x;
|
||||
} else {
|
||||
// add waste area
|
||||
int under_width = node->next->x - node->x;
|
||||
if (under_width + visited_width > width)
|
||||
under_width = width - visited_width;
|
||||
waste_area += under_width * (min_y - node->y);
|
||||
visited_width += under_width;
|
||||
}
|
||||
node = node->next;
|
||||
}
|
||||
|
||||
*pwaste = waste_area;
|
||||
return min_y;
|
||||
}
|
||||
|
||||
typedef struct
|
||||
{
|
||||
int x,y;
|
||||
stbrp_node **prev_link;
|
||||
} stbrp__findresult;
|
||||
|
||||
static stbrp__findresult stbrp__skyline_find_best_pos(stbrp_context *c, int width, int height)
|
||||
{
|
||||
int best_waste = (1<<30), best_x, best_y = (1 << 30);
|
||||
stbrp__findresult fr;
|
||||
stbrp_node **prev, *node, *tail, **best = NULL;
|
||||
|
||||
// align to multiple of c->align
|
||||
width = (width + c->align - 1);
|
||||
width -= width % c->align;
|
||||
STBRP_ASSERT(width % c->align == 0);
|
||||
|
||||
// if it can't possibly fit, bail immediately
|
||||
if (width > c->width || height > c->height) {
|
||||
fr.prev_link = NULL;
|
||||
fr.x = fr.y = 0;
|
||||
return fr;
|
||||
}
|
||||
|
||||
node = c->active_head;
|
||||
prev = &c->active_head;
|
||||
while (node->x + width <= c->width) {
|
||||
int y,waste;
|
||||
y = stbrp__skyline_find_min_y(c, node, node->x, width, &waste);
|
||||
if (c->heuristic == STBRP_HEURISTIC_Skyline_BL_sortHeight) { // actually just want to test BL
|
||||
// bottom left
|
||||
if (y < best_y) {
|
||||
best_y = y;
|
||||
best = prev;
|
||||
}
|
||||
} else {
|
||||
// best-fit
|
||||
if (y + height <= c->height) {
|
||||
// can only use it if it first vertically
|
||||
if (y < best_y || (y == best_y && waste < best_waste)) {
|
||||
best_y = y;
|
||||
best_waste = waste;
|
||||
best = prev;
|
||||
}
|
||||
}
|
||||
}
|
||||
prev = &node->next;
|
||||
node = node->next;
|
||||
}
|
||||
|
||||
best_x = (best == NULL) ? 0 : (*best)->x;
|
||||
|
||||
// if doing best-fit (BF), we also have to try aligning right edge to each node position
|
||||
//
|
||||
// e.g, if fitting
|
||||
//
|
||||
// ____________________
|
||||
// |____________________|
|
||||
//
|
||||
// into
|
||||
//
|
||||
// | |
|
||||
// | ____________|
|
||||
// |____________|
|
||||
//
|
||||
// then right-aligned reduces waste, but bottom-left BL is always chooses left-aligned
|
||||
//
|
||||
// This makes BF take about 2x the time
|
||||
|
||||
if (c->heuristic == STBRP_HEURISTIC_Skyline_BF_sortHeight) {
|
||||
tail = c->active_head;
|
||||
node = c->active_head;
|
||||
prev = &c->active_head;
|
||||
// find first node that's admissible
|
||||
while (tail->x < width)
|
||||
tail = tail->next;
|
||||
while (tail) {
|
||||
int xpos = tail->x - width;
|
||||
int y,waste;
|
||||
STBRP_ASSERT(xpos >= 0);
|
||||
// find the left position that matches this
|
||||
while (node->next->x <= xpos) {
|
||||
prev = &node->next;
|
||||
node = node->next;
|
||||
}
|
||||
STBRP_ASSERT(node->next->x > xpos && node->x <= xpos);
|
||||
y = stbrp__skyline_find_min_y(c, node, xpos, width, &waste);
|
||||
if (y + height <= c->height) {
|
||||
if (y <= best_y) {
|
||||
if (y < best_y || waste < best_waste || (waste==best_waste && xpos < best_x)) {
|
||||
best_x = xpos;
|
||||
//STBRP_ASSERT(y <= best_y); [DEAR IMGUI]
|
||||
best_y = y;
|
||||
best_waste = waste;
|
||||
best = prev;
|
||||
}
|
||||
}
|
||||
}
|
||||
tail = tail->next;
|
||||
}
|
||||
}
|
||||
|
||||
fr.prev_link = best;
|
||||
fr.x = best_x;
|
||||
fr.y = best_y;
|
||||
return fr;
|
||||
}
|
||||
|
||||
static stbrp__findresult stbrp__skyline_pack_rectangle(stbrp_context *context, int width, int height)
|
||||
{
|
||||
// find best position according to heuristic
|
||||
stbrp__findresult res = stbrp__skyline_find_best_pos(context, width, height);
|
||||
stbrp_node *node, *cur;
|
||||
|
||||
// bail if:
|
||||
// 1. it failed
|
||||
// 2. the best node doesn't fit (we don't always check this)
|
||||
// 3. we're out of memory
|
||||
if (res.prev_link == NULL || res.y + height > context->height || context->free_head == NULL) {
|
||||
res.prev_link = NULL;
|
||||
return res;
|
||||
}
|
||||
|
||||
// on success, create new node
|
||||
node = context->free_head;
|
||||
node->x = (stbrp_coord) res.x;
|
||||
node->y = (stbrp_coord) (res.y + height);
|
||||
|
||||
context->free_head = node->next;
|
||||
|
||||
// insert the new node into the right starting point, and
|
||||
// let 'cur' point to the remaining nodes needing to be
|
||||
// stiched back in
|
||||
|
||||
cur = *res.prev_link;
|
||||
if (cur->x < res.x) {
|
||||
// preserve the existing one, so start testing with the next one
|
||||
stbrp_node *next = cur->next;
|
||||
cur->next = node;
|
||||
cur = next;
|
||||
} else {
|
||||
*res.prev_link = node;
|
||||
}
|
||||
|
||||
// from here, traverse cur and free the nodes, until we get to one
|
||||
// that shouldn't be freed
|
||||
while (cur->next && cur->next->x <= res.x + width) {
|
||||
stbrp_node *next = cur->next;
|
||||
// move the current node to the free list
|
||||
cur->next = context->free_head;
|
||||
context->free_head = cur;
|
||||
cur = next;
|
||||
}
|
||||
|
||||
// stitch the list back in
|
||||
node->next = cur;
|
||||
|
||||
if (cur->x < res.x + width)
|
||||
cur->x = (stbrp_coord) (res.x + width);
|
||||
|
||||
#ifdef _DEBUG
|
||||
cur = context->active_head;
|
||||
while (cur->x < context->width) {
|
||||
STBRP_ASSERT(cur->x < cur->next->x);
|
||||
cur = cur->next;
|
||||
}
|
||||
STBRP_ASSERT(cur->next == NULL);
|
||||
|
||||
{
|
||||
int count=0;
|
||||
cur = context->active_head;
|
||||
while (cur) {
|
||||
cur = cur->next;
|
||||
++count;
|
||||
}
|
||||
cur = context->free_head;
|
||||
while (cur) {
|
||||
cur = cur->next;
|
||||
++count;
|
||||
}
|
||||
STBRP_ASSERT(count == context->num_nodes+2);
|
||||
}
|
||||
#endif
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
static int STBRP__CDECL rect_height_compare(const void *a, const void *b)
|
||||
{
|
||||
const stbrp_rect *p = (const stbrp_rect *) a;
|
||||
const stbrp_rect *q = (const stbrp_rect *) b;
|
||||
if (p->h > q->h)
|
||||
return -1;
|
||||
if (p->h < q->h)
|
||||
return 1;
|
||||
return (p->w > q->w) ? -1 : (p->w < q->w);
|
||||
}
|
||||
|
||||
static int STBRP__CDECL rect_original_order(const void *a, const void *b)
|
||||
{
|
||||
const stbrp_rect *p = (const stbrp_rect *) a;
|
||||
const stbrp_rect *q = (const stbrp_rect *) b;
|
||||
return (p->was_packed < q->was_packed) ? -1 : (p->was_packed > q->was_packed);
|
||||
}
|
||||
|
||||
STBRP_DEF int stbrp_pack_rects(stbrp_context *context, stbrp_rect *rects, int num_rects)
|
||||
{
|
||||
int i, all_rects_packed = 1;
|
||||
|
||||
// we use the 'was_packed' field internally to allow sorting/unsorting
|
||||
for (i=0; i < num_rects; ++i) {
|
||||
rects[i].was_packed = i;
|
||||
}
|
||||
|
||||
// sort according to heuristic
|
||||
STBRP_SORT(rects, num_rects, sizeof(rects[0]), rect_height_compare);
|
||||
|
||||
for (i=0; i < num_rects; ++i) {
|
||||
if (rects[i].w == 0 || rects[i].h == 0) {
|
||||
rects[i].x = rects[i].y = 0; // empty rect needs no space
|
||||
} else {
|
||||
stbrp__findresult fr = stbrp__skyline_pack_rectangle(context, rects[i].w, rects[i].h);
|
||||
if (fr.prev_link) {
|
||||
rects[i].x = (stbrp_coord) fr.x;
|
||||
rects[i].y = (stbrp_coord) fr.y;
|
||||
} else {
|
||||
rects[i].x = rects[i].y = STBRP__MAXVAL;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// unsort
|
||||
STBRP_SORT(rects, num_rects, sizeof(rects[0]), rect_original_order);
|
||||
|
||||
// set was_packed flags and all_rects_packed status
|
||||
for (i=0; i < num_rects; ++i) {
|
||||
rects[i].was_packed = !(rects[i].x == STBRP__MAXVAL && rects[i].y == STBRP__MAXVAL);
|
||||
if (!rects[i].was_packed)
|
||||
all_rects_packed = 0;
|
||||
}
|
||||
|
||||
// return the all_rects_packed status
|
||||
return all_rects_packed;
|
||||
}
|
||||
#endif
|
||||
|
||||
/*
|
||||
------------------------------------------------------------------------------
|
||||
This software is available under 2 licenses -- choose whichever you prefer.
|
||||
------------------------------------------------------------------------------
|
||||
ALTERNATIVE A - MIT License
|
||||
Copyright (c) 2017 Sean Barrett
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
------------------------------------------------------------------------------
|
||||
ALTERNATIVE B - Public Domain (www.unlicense.org)
|
||||
This is free and unencumbered software released into the public domain.
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or distribute this
|
||||
software, either in source code form or as a compiled binary, for any purpose,
|
||||
commercial or non-commercial, and by any means.
|
||||
In jurisdictions that recognize copyright laws, the author or authors of this
|
||||
software dedicate any and all copyright interest in the software to the public
|
||||
domain. We make this dedication for the benefit of the public at large and to
|
||||
the detriment of our heirs and successors. We intend this dedication to be an
|
||||
overt act of relinquishment in perpetuity of all present and future rights to
|
||||
this software under copyright law.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
|
||||
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
------------------------------------------------------------------------------
|
||||
*/
|
||||
+1469
File diff suppressed because it is too large.
Load diff
+5085
File diff suppressed because it is too large.
Load diff
+24765
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,26 @@
|
||||
# Builds the release and packs what players download: dist\bbbridge-<version>-windows.zip
|
||||
# holding a "bbbridge" folder to drop into bbport's folder (next to Bloodborne.exe).
|
||||
# Never includes an account file, logs or build output.
|
||||
# powershell -ExecutionPolicy Bypass -File tools\make_release.ps1
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$vc = 'C:\Program Files\Microsoft Visual Studio\18\Community\VC\Auxiliary\Build\vcvars64.bat'
|
||||
if (-not (Test-Path $vc)) {
|
||||
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
|
||||
$vc = Join-Path (& $vswhere -latest -products * -property installationPath) 'VC\Auxiliary\Build\vcvars64.bat'
|
||||
}
|
||||
cmd /c "`"$vc`" >nul && cd /d `"$root`" && cmake --preset x64-release >nul && cmake --build --preset x64-release --target bbbridge"
|
||||
if ($LASTEXITCODE -ne 0) { throw 'build failed' }
|
||||
|
||||
$version = (Select-String -Path "$root\CMakeLists.txt" -Pattern 'project\(bbbridge VERSION ([0-9.]+)').Matches[0].Groups[1].Value
|
||||
$stage = Join-Path $root "dist\stage\bbbridge"
|
||||
Remove-Item (Join-Path $root 'dist\stage') -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force $stage | Out-Null
|
||||
Copy-Item "$root\build\x64-release\bbbridge.exe", "$root\README.md", "$root\CHANGELOG.md", "$root\LICENSE", "$root\THIRD_PARTY_NOTICES.txt" $stage
|
||||
$zip = Join-Path $root "dist\bbbridge-$version-windows.zip"
|
||||
Remove-Item $zip -Force -ErrorAction SilentlyContinue
|
||||
Compress-Archive -Path $stage -DestinationPath $zip
|
||||
Remove-Item (Join-Path $root 'dist\stage') -Recurse -Force
|
||||
$hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower()
|
||||
"$hash $(Split-Path -Leaf $zip)" | Set-Content -Encoding ascii "$zip.sha256"
|
||||
Write-Output "$zip ($([math]::Round((Get-Item $zip).Length / 1MB, 2)) MB) sha256 $hash"
|
||||
@@ -0,0 +1,25 @@
|
||||
# bbport's changes to BBSC
|
||||
|
||||
This folder holds [BBSC](https://github.com/Classmg22/BBSC) 0.0.1 by CLS_AMG (GPL-2.0-or-later),
|
||||
as published at commit 315f76c, with the changes below made for the Linux port of bbport. Changed
|
||||
lines in BBSC's own files are marked `bbport:` in comments.
|
||||
|
||||
## 2026-10-15
|
||||
|
||||
- **Linux build.** `CMakeLists.txt` builds `libbbsc.so`, which bb-probe loads into the game.
|
||||
- New files `emulator/mod/linux/`:
|
||||
- `bbsc_mod_linux.cpp`, which replaces `bbsc_mod.cpp`;
|
||||
- `bbsc_hooks_linux.cpp`, which replaces the Windows hook code;
|
||||
- `bbsc_win32.h`, the Win32 and Winsock names the mod uses.
|
||||
- Linux branches (`#ifdef`) in `emulator/mod/bbsc_relay.cpp`, `bbsc_channel.cpp` and
|
||||
`bbsc_http.cpp`.
|
||||
- **Logs and captures.** BBSC keeps 5 of each, and stops a capture at 64 MB.
|
||||
- **Data folder.** BBSC's files go in the game's user folder, not the working directory.
|
||||
- **Memory search on Linux.** `emulator/src/core/bbsc_memscan.cpp` reads the readable mappings
|
||||
through `process_vm_readv`.
|
||||
- **F8 key.** The F8 unstuck key works on Linux through SDL (`emulator/src/core/bloodborne_re.cpp`).
|
||||
- **Saves.** `BBSC_SAVE_DIR` is set to bbport's save folder (`savedata/<user>/<title id>/SPRJ0005`).
|
||||
- **Translations.** BBSC's texts follow the game's language:
|
||||
- `emulator/src/core/bbsc_i18n.{h,cpp}` and `bbsc_translations.inc`, generated from
|
||||
`translations/*.json` by `translations/gen_translations.py`;
|
||||
- in `emulator/src/core/bloodborne_re.cpp`, BBSC's texts are passed through them.
|
||||
@@ -0,0 +1,49 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
# bbport: libbbsc.so, BBSC (Bloodborne Seamless Co-op, CLS_AMG) for the Linux port. The mod's
|
||||
# own build (emulator/mod/build-mod.cmd) makes bbsc.dll for Windows; this one builds the same
|
||||
# game logic (emulator/src/core/bloodborne_re.cpp with BBSC_MOD) with the mod's network files and
|
||||
# the Linux counterparts of its platform files (emulator/mod/linux/). bb-probe loads it into the
|
||||
# game's process (BB_BBSC_LIB) and calls bbsc_start().
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(bbsc CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 23)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
||||
if(NOT CMAKE_BUILD_TYPE)
|
||||
set(CMAKE_BUILD_TYPE Release)
|
||||
endif()
|
||||
|
||||
# Header-only, kept in third_party/ (xbyak 7.37.3, BSD-3-Clause; fmt 12.2.0, MIT) so the build does
|
||||
# not depend on what the system has.
|
||||
set(XBYAK_INCLUDE ${CMAKE_CURRENT_SOURCE_DIR}/third_party/xbyak)
|
||||
set(FMT_INCLUDE ${CMAKE_CURRENT_SOURCE_DIR}/third_party/fmt/include)
|
||||
|
||||
add_library(bbsc SHARED
|
||||
emulator/src/core/bloodborne_re.cpp
|
||||
emulator/src/core/bbsc_memscan.cpp
|
||||
emulator/src/core/bbsc_i18n.cpp
|
||||
emulator/mod/bbsc_relay.cpp
|
||||
emulator/mod/bbsc_channel.cpp
|
||||
emulator/mod/bbsc_http.cpp
|
||||
emulator/mod/linux/bbsc_mod_linux.cpp
|
||||
emulator/mod/linux/bbsc_hooks_linux.cpp)
|
||||
target_compile_definitions(bbsc PRIVATE BBSC_MOD FMT_HEADER_ONLY)
|
||||
target_include_directories(bbsc PRIVATE emulator/mod emulator/mod/shim emulator/src
|
||||
${XBYAK_INCLUDE} ${FMT_INCLUDE})
|
||||
# Only bbsc_start is for bb-probe; the game's runtime symbols it calls (runtime_memory_vma_info)
|
||||
# come from bb-probe itself, which exports them.
|
||||
set_target_properties(bbsc PROPERTIES CXX_VISIBILITY_PRESET hidden VISIBILITY_INLINES_HIDDEN ON)
|
||||
target_link_options(bbsc PRIVATE -Wl,-z,undefs)
|
||||
find_package(Threads REQUIRED)
|
||||
target_link_libraries(bbsc PRIVATE Threads::Threads)
|
||||
|
||||
# BBSC turns itself off without its license, notice and supporters list next to it (as the
|
||||
# Windows mod folder has them).
|
||||
add_custom_command(TARGET bbsc POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${CMAKE_CURRENT_SOURCE_DIR}/LICENSE
|
||||
$<TARGET_FILE_DIR:bbsc>/LICENSE.txt
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${CMAKE_CURRENT_SOURCE_DIR}/NOTICE.md
|
||||
$<TARGET_FILE_DIR:bbsc>/NOTICE.txt
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${CMAKE_CURRENT_SOURCE_DIR}/SUPPORTERS.txt
|
||||
$<TARGET_FILE_DIR:bbsc>/SUPPORTERS.txt)
|
||||
@@ -0,0 +1,36 @@
|
||||
BBSC - Bloodborne Seamless Co-op
|
||||
Credits
|
||||
|
||||
MADE BY
|
||||
CLS_AMG
|
||||
|
||||
TESTED BY
|
||||
fra98_ (framilano)
|
||||
Hardwater
|
||||
Onesadeye
|
||||
aboood888848
|
||||
Fahad
|
||||
Mob
|
||||
lelial
|
||||
J2mF
|
||||
Kukar3x
|
||||
|
||||
SPECIAL THANKS
|
||||
framilano (fra98_) - Bloodborne Nucleus Co-op Handler
|
||||
split-screen Bloodborne on one PC; BBSC runs in it
|
||||
https://github.com/framilano/BloodborneNucleusCoopHandler
|
||||
Fahad (fhody125) - No Dream For The Hunter
|
||||
the game server and ss.info groundwork BBSC's servers grew from
|
||||
|
||||
BUILT ON
|
||||
shadPS4 - the PS4 emulator (shadPS4 team and contributors, GPL-2.0)
|
||||
shadp2p by Wozzardman - the peer-to-peer online fork BBSC is built on
|
||||
|
||||
ONLINE
|
||||
shadNet team - shadNet, the official shadPS4 online service (sessions, matchmaking)
|
||||
The Hunter's Dream team - the public Bloodborne game server
|
||||
|
||||
BLOODBORNE
|
||||
Bloodborne is developed by FromSoftware and published by Sony Interactive Entertainment.
|
||||
BBSC is a free fan-made project. It is not affiliated with or endorsed by FromSoftware or Sony.
|
||||
You need your own copy of the game. No game files are included.
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Lesser General Public License instead.) You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
this service if you wish), that you receive source code or can get it
|
||||
if you want it, that you can change the software or use pieces of it
|
||||
in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. You must make sure that they, too, receive or can get the
|
||||
source code. And you must show them these terms so they know their
|
||||
rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's
|
||||
source code as you receive it, in any medium, provided that you
|
||||
conspicuously and appropriately publish on each copy an appropriate
|
||||
copyright notice and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||
REPAIR OR CORRECTION.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1989
|
||||
Ty Coon, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into
|
||||
proprietary programs. If your program is a subroutine library, you may
|
||||
consider it more useful to permit linking proprietary applications with the
|
||||
library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License.
|
||||
@@ -0,0 +1,52 @@
|
||||
BBSC - Bloodborne Seamless Co-op
|
||||
Copyright (C) 2026 CLS_AMG
|
||||
|
||||
This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
GNU General Public License as published by the Free Software Foundation; either version 2 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with this program (the
|
||||
file LICENSE / LICENSE.txt); if not, write to the Free Software Foundation, Inc., 51 Franklin
|
||||
Street, Fifth Floor, Boston, MA 02110-1301 USA, or see <https://www.gnu.org/licenses/>.
|
||||
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
|
||||
Source code
|
||||
-----------
|
||||
The complete source code of this program (the emulator fork, the BBSC mod, the launcher and the
|
||||
host servers' scripts) is available at:
|
||||
|
||||
https://github.com/Classmg22/BBSC
|
||||
|
||||
This offer is valid for at least three years from the date you received this copy.
|
||||
|
||||
|
||||
Third-party work this program is built on
|
||||
-----------------------------------------
|
||||
- shadPS4, the PS4 emulator - Copyright (C) the shadPS4 Emulator Project and contributors,
|
||||
GPL-2.0-or-later. https://github.com/shadps4-emu/shadPS4
|
||||
- shadp2p, the peer-to-peer online fork BBSC is built on - Copyright (C) Wozzardman,
|
||||
GPL-2.0-or-later.
|
||||
- Files taken from those projects keep their own copyright lines and SPDX headers. Some bundled
|
||||
libraries are under other free licenses (MIT, BSD-3-Clause, BSL-1.0, CC0-1.0, OFL-1.1, LGPL for
|
||||
Qt and FFmpeg); each keeps its own notice.
|
||||
|
||||
|
||||
Thanks
|
||||
------
|
||||
- framilano (fra98_) - Bloodborne Nucleus Co-op Handler (split-screen Bloodborne on one PC).
|
||||
- Fahad (fhody125) - No Dream For The Hunter, the game server and ss.info groundwork.
|
||||
- Testers: fra98_ (framilano), Hardwater, Onesadeye, aboood888848, Fahad, Mob, lelial.
|
||||
|
||||
|
||||
Bloodborne
|
||||
----------
|
||||
Bloodborne is a trademark of Sony Interactive Entertainment. The game is developed by
|
||||
FromSoftware and published by Sony Interactive Entertainment. BBSC is a free fan-made project and
|
||||
is not affiliated with, endorsed or sponsored by FromSoftware or Sony Interactive Entertainment.
|
||||
No game files are included; you need your own legally obtained copy of the game.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Bloodborne Seamless Co-op (BBSC)
|
||||
|
||||
Play the whole of Bloodborne together. BBSC is a mod for the [shadPS4](https://github.com/shadps4-emu/shadPS4)
|
||||
emulator that keeps a co-op session alive through deaths, lamps, area changes, bosses and the Hunter's
|
||||
Dream - up to five hunters, each keeping their own character.
|
||||
|
||||
**Downloads:** Nexus Mods (link coming with the release) · **Community:** [Discord](https://discord.gg/Df7BCmfWdt)
|
||||
|
||||
> 0.0.1 is the first public test. Back up your saves before playing.
|
||||
|
||||
## Features
|
||||
|
||||
- One co-op session that lasts through deaths, lamps, area changes, bosses and the Hunter's Dream
|
||||
- Up to 5 hunters: the host and 4 guests
|
||||
- Guests follow the host between areas and lamps and respawn with the group
|
||||
- Everyone keeps their own levels, echoes and items
|
||||
- Shared progress (optional): the host's boss kills and shortcuts are added to the guest's world
|
||||
- Guests get boss rewards and enemy drops; items already picked up do not come back
|
||||
- Enemy scaling by the number of hunters
|
||||
- A co-op kit with no Insight cost: Hunt Bell, Kin Bell, Bloodlust Bell, Parting Blank, Duel Bell (PvP toggle)
|
||||
- Clear co-op messages in place of the vanilla "beckoner / resonance" lines
|
||||
- Public server, self-hosted servers (Radmin VPN, Tailscale, LAN, VPS) and split screen (Nucleus Co-op)
|
||||
- Runs in shadPS4 and in the BBport Windows build
|
||||
|
||||
## Requirements
|
||||
|
||||
- Your own Bloodborne **CUSA03173, version 01.09** - no game files are included
|
||||
- Windows (the server also runs on Linux)
|
||||
|
||||
## Downloads
|
||||
|
||||
| File | For |
|
||||
|---|---|
|
||||
| `BBSC-<version>-Play.zip` | Players: launcher + emulator + BBSC, connects to the public server |
|
||||
| `BBSC-<version>-Server.zip` | Hosting your own server (Windows) |
|
||||
| `BBSC-<version>-Nucleus.zip` | Split screen with the [Bloodborne Nucleus Co-op Handler](https://github.com/framilano/BloodborneNucleusCoopHandler) |
|
||||
| `BBSC-<version>-BBport.zip` | Test build for the [BBport Windows build](https://github.com/Supermedo/bloodborne_pc) - same server as shadPS4 players |
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Unzip the Play package and keep its files together.
|
||||
2. Run `PLAY BBSC.cmd`. Your shadPS4 settings, patches and saves are used as they are.
|
||||
3. Launcher > Settings > Accounts > Create Account on Server.
|
||||
4. Host rings the Hunt Bell, guests ring the Kin Bell.
|
||||
|
||||
## Repository layout
|
||||
|
||||
| Folder | Contents |
|
||||
|---|---|
|
||||
| `emulator/` | The shadPS4 fork (shadp2p based). BBSC game code: `src/core/bloodborne_re.cpp`; the mod DLL: `mod/` |
|
||||
| `launcher/` | The Qt launcher fork (Mods and Accounts tabs) |
|
||||
| `server/` | Host package scripts and the BBSC game server (`bbsc_gameserver.py`) |
|
||||
| `tools/` | Game data readers and helper tools |
|
||||
| `docs/` | Architecture, world sync, networking, known limitations, changelog |
|
||||
|
||||
## Building
|
||||
|
||||
- Emulator: `build/build-shadp2p.cmd` (Windows, Visual Studio 2022, CMake, Ninja)
|
||||
- Mod DLL: `emulator/mod/build-mod.cmd`
|
||||
- Game server: Python 3, no extra packages (`server/gameserver/bbsc_gameserver.py --shadnet <address>`)
|
||||
|
||||
## Reporting bugs
|
||||
|
||||
Post in `#bug-reports` on the [Discord](https://discord.gg/Df7BCmfWdt) with the version, host or guest,
|
||||
what happened and when, and both logs: the BBSC log (`mods/CUSA03173/bbsc/logs/`) and the emulator log
|
||||
(`user/log/shad_log.txt` or `%APPDATA%\shadPS4\log\`).
|
||||
|
||||
## Credits
|
||||
|
||||
- **CLS_AMG** - BBSC
|
||||
- **framilano (fra98_)** - Bloodborne Nucleus Co-op Handler; tester
|
||||
- **Wozzardman** - [shadp2p](https://github.com/Wozzardman/shadp2p) and shadnet-p2p, the online fork BBSC is built on
|
||||
- **Fahad (fhody125)** - No Dream For The Hunter, the game server groundwork (MIT)
|
||||
- **shadPS4 team and contributors** - the emulator; **shadNet team** - shadNet
|
||||
- **The Hunter's Dream team**
|
||||
- **deadinside28** (bbport) and **Supermedo** (its Windows build)
|
||||
- **Testers** - Hardwater, Onesadeye, aboood888848, Fahad, Mob, lelial, J2mF, Kukar3x
|
||||
- Supporters: see `SUPPORTERS.txt`
|
||||
|
||||
## Support
|
||||
|
||||
BBSC is free for everyone. If you want to support the time that goes into it:
|
||||
[Patreon](https://www.patreon.com/c/CLS_AMG) or [Buy Me a Coffee](https://buymeacoffee.com/j2mf).
|
||||
|
||||
## License
|
||||
|
||||
GPL-2.0-or-later, like shadPS4. See `LICENSE` and `NOTICE.md`. Third-party parts keep their own licenses.
|
||||
|
||||
Bloodborne is developed by FromSoftware and published by Sony Interactive Entertainment. BBSC is a free
|
||||
fan project and is not affiliated with or endorsed by either.
|
||||
@@ -0,0 +1,24 @@
|
||||
BBSC SUPPORTERS
|
||||
===============
|
||||
|
||||
BBSC is free for everyone, and every build and feature reaches everyone on the same day.
|
||||
These people support the time that goes into it. Thank you.
|
||||
|
||||
Support BBSC:
|
||||
- Patreon: https://www.patreon.com/c/CLS_AMG
|
||||
- Buy Me a Coffee: https://buymeacoffee.com/j2mf
|
||||
|
||||
|
||||
WORKSHOP
|
||||
--------
|
||||
(no one yet)
|
||||
|
||||
|
||||
HUNTER
|
||||
------
|
||||
(no one yet)
|
||||
|
||||
|
||||
LAMPLIGHT
|
||||
---------
|
||||
(no one yet)
|
||||
@@ -0,0 +1,369 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// BBSC mod: the P2P channel - BBSC's own packets over the game's own player-to-player connection,
|
||||
// for servers that run no BBSC relay (the public ones: The Hunter's Dream + shadNet).
|
||||
//
|
||||
// The players of a session already exchange P2P datagrams (the game's socket, vport 40). The
|
||||
// emulator's P2P transport (official shadPS4: core/net/p2p_transport.cpp; the shadp2p fork:
|
||||
// network/p2p_sockets.cpp) carries a source and destination vport in every frame and hands each
|
||||
// frame to the socket bound to that vport, so a second socket on a vport of our own sees only
|
||||
// BBSC's frames and the game never sees them.
|
||||
//
|
||||
// - Peers: the game's import slots for sceNetSendto/sceNetRecvfrom are wrapped; every address
|
||||
// the game sends to or receives from on a P2P vport is a player of our session (kept while
|
||||
// the game talks to it, dropped 20 s after). sceNpSignalingGetConnectionStatus is wrapped too
|
||||
// (ACTIVE connections give the peer's address).
|
||||
// - Socket: our own DGRAM_P2P socket on ChannelVport, made through the emulator's sceNet
|
||||
// functions (the originals from the same slots), non-blocking, read by our own thread.
|
||||
// - Frames: the relay's frames unchanged (the "SHAD" 0x40 envelope with sender + session,
|
||||
// bbsc_relay.cpp), one datagram each; the receive side is the relay's Dispatch.
|
||||
//
|
||||
// Import slots, CUSA03173 01.09 (tools\eboot-imports.py, BBSC offsets):
|
||||
// 0x053E4B00 sceNetSocket 0x053E4B38 sceNetBind 0x053E4B68 sceNetSetsockopt
|
||||
// 0x053E4F48 sceNetSendto 0x053E4F58 sceNetRecvfrom
|
||||
// 0x053E52D0 sceNpSignalingGetConnectionStatus
|
||||
// Switch SHADPS4_BBSC_P2P_CHANNEL (Mods tab, on). Datagrams can be lost: every BBSC packet was
|
||||
// made for the emulator's UDP signaling first and is re-sent or repeated by its sender.
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include "linux/bbsc_win32.h" // bbport
|
||||
#endif
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "common/logging/log.h"
|
||||
#include "common/types.h"
|
||||
|
||||
namespace BbscMod {
|
||||
u64 EbootAddress(); // bbsc_mod.cpp
|
||||
void GuardSlot(u64 slot_offset, u64 wrapper, void** original, const char* name);
|
||||
void RelayDispatch(const std::vector<u8>& wrapped); // bbsc_relay.cpp
|
||||
} // namespace BbscMod
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr u64 EbootSize = 0x056D4000ULL;
|
||||
constexpr u64 SlotSocket = 0x053E4B00;
|
||||
constexpr u64 SlotSocketClose = 0x053E4B08;
|
||||
constexpr u64 SlotBind = 0x053E4B38;
|
||||
constexpr u64 SlotSetsockopt = 0x053E4B68;
|
||||
constexpr u64 SlotSendto = 0x053E4F48;
|
||||
constexpr u64 SlotRecvfrom = 0x053E4F58;
|
||||
constexpr u64 SlotConnectionStatus = 0x053E52D0;
|
||||
|
||||
constexpr u16 ChannelVport = 3173; // below 32768 (official shadPS4 reserves the rest); game uses 40
|
||||
constexpr s32 OrbisAfInet = 2;
|
||||
constexpr s32 OrbisSockDgramP2P = 6;
|
||||
constexpr s32 OrbisSolSocket = 0xFFFF;
|
||||
constexpr s32 OrbisSoNbio = 0x1200;
|
||||
constexpr s32 OrbisMsgDontwait = 0x80;
|
||||
constexpr u32 SignalingConnActive = 2;
|
||||
constexpr s64 PeerKeepMs = 20'000;
|
||||
constexpr size_t MaxDatagram = 2048;
|
||||
|
||||
#pragma pack(push, 1)
|
||||
struct OrbisSockaddrIn {
|
||||
u8 sin_len;
|
||||
u8 sin_family;
|
||||
u16 sin_port; // network order (P2P datagrams: the peer's UDP port)
|
||||
u32 sin_addr; // network order
|
||||
u16 sin_vport; // network order
|
||||
char sin_zero[6];
|
||||
};
|
||||
#pragma pack(pop)
|
||||
static_assert(sizeof(OrbisSockaddrIn) == 16);
|
||||
|
||||
using SocketFn = s32 PS4_SYSV_ABI (*)(const char*, s32, s32, s32);
|
||||
using SocketCloseFn = s32 PS4_SYSV_ABI (*)(s32);
|
||||
using BindFn = s32 PS4_SYSV_ABI (*)(s32, const void*, u32);
|
||||
using SetsockoptFn = s32 PS4_SYSV_ABI (*)(s32, s32, s32, const void*, u32);
|
||||
using SendtoFn = s32 PS4_SYSV_ABI (*)(s32, const void*, u64, s32, const void*, u32);
|
||||
using RecvfromFn = s32 PS4_SYSV_ABI (*)(s32, void*, u64, s32, void*, u32*);
|
||||
using ConnectionStatusFn = s32 PS4_SYSV_ABI (*)(s32, s32, u32*, u32*, u16*);
|
||||
|
||||
SendtoFn original_sendto = nullptr;
|
||||
RecvfromFn original_recvfrom = nullptr;
|
||||
ConnectionStatusFn original_connection_status = nullptr;
|
||||
|
||||
struct Peer {
|
||||
s64 seen_ms = 0;
|
||||
bool logged = false;
|
||||
};
|
||||
|
||||
std::mutex peers_mutex;
|
||||
std::map<u64, Peer> peers; // key: addr << 16 | port (both network order)
|
||||
std::atomic<s32> channel_socket{-1};
|
||||
std::atomic<bool> enabled{false};
|
||||
std::atomic<u32> sent_count{0};
|
||||
std::atomic<u32> received_count{0};
|
||||
|
||||
s64 NowMs() {
|
||||
return static_cast<s64>(GetTickCount64());
|
||||
}
|
||||
|
||||
std::string AddressText(u32 addr, u16 port) {
|
||||
const auto* b = reinterpret_cast<const u8*>(&addr);
|
||||
return fmt::format("{}.{}.{}.x:{}", b[0], b[1], b[2], static_cast<u16>((port >> 8) | (port << 8)));
|
||||
}
|
||||
|
||||
void NotePeer(u32 addr, u16 port, const char* how) {
|
||||
if (addr == 0 || port == 0) {
|
||||
return;
|
||||
}
|
||||
const u64 key = (static_cast<u64>(addr) << 16) | port;
|
||||
std::scoped_lock lock{peers_mutex};
|
||||
Peer& peer = peers[key];
|
||||
const bool fresh = !peer.logged || NowMs() - peer.seen_ms > PeerKeepMs;
|
||||
peer.seen_ms = NowMs();
|
||||
if (fresh) {
|
||||
peer.logged = true;
|
||||
LOG_INFO(Mod, "P2P channel: player at {} ({})", AddressText(addr, port), how);
|
||||
}
|
||||
}
|
||||
|
||||
void NoteGameAddress(const void* addr, u32 len, const char* how) {
|
||||
if (!enabled || addr == nullptr || len < sizeof(OrbisSockaddrIn)) {
|
||||
return;
|
||||
}
|
||||
OrbisSockaddrIn in{};
|
||||
std::memcpy(&in, addr, sizeof(in));
|
||||
// A P2P address has a vport; plain UDP/TCP sockets of the game leave it 0.
|
||||
if (in.sin_family != OrbisAfInet || in.sin_vport == 0 ||
|
||||
in.sin_vport == static_cast<u16>((ChannelVport >> 8) | (ChannelVport << 8))) {
|
||||
return;
|
||||
}
|
||||
NotePeer(in.sin_addr, in.sin_port, how);
|
||||
}
|
||||
|
||||
s32 PS4_SYSV_ABI Sendto(s32 s, const void* buf, u64 len, s32 flags, const void* addr, u32 addrlen) {
|
||||
const s32 r = original_sendto(s, buf, len, flags, addr, addrlen);
|
||||
if (r > 0 && s != channel_socket) {
|
||||
NoteGameAddress(addr, addrlen, "game sends to it");
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
s32 PS4_SYSV_ABI Recvfrom(s32 s, void* buf, u64 len, s32 flags, void* addr, u32* addrlen) {
|
||||
const s32 r = original_recvfrom(s, buf, len, flags, addr, addrlen);
|
||||
if (r > 0 && addrlen != nullptr && s != channel_socket) {
|
||||
NoteGameAddress(addr, *addrlen, "game hears from it");
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
s32 PS4_SYSV_ABI ConnectionStatus(s32 ctx, s32 conn, u32* status, u32* addr, u16* port) {
|
||||
// The game may pass no address pointers: ask with our own, hand back what it asked for.
|
||||
u32 own_addr = 0;
|
||||
u16 own_port = 0;
|
||||
const s32 r = original_connection_status(ctx, conn, status, addr ? addr : &own_addr,
|
||||
port ? port : &own_port);
|
||||
if (r == 0 && status != nullptr && *status == SignalingConnActive) {
|
||||
NotePeer(addr ? *addr : own_addr, port ? *port : own_port, "signaling connection active");
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
template <typename Fn>
|
||||
bool Swap(u64 slot_offset, Fn wrapper, Fn& original, const char* name) {
|
||||
auto* slot = reinterpret_cast<u64*>(BbscMod::EbootAddress() + slot_offset);
|
||||
for (int i = 0; i < 60'000; ++i) {
|
||||
const u64 value = *slot;
|
||||
if (value != 0 &&
|
||||
(value < BbscMod::EbootAddress() || value >= BbscMod::EbootAddress() + EbootSize)) {
|
||||
original = reinterpret_cast<Fn>(value);
|
||||
DWORD old = 0;
|
||||
VirtualProtect(slot, 8, PAGE_READWRITE, &old);
|
||||
*slot = reinterpret_cast<u64>(wrapper);
|
||||
DWORD ignored = 0;
|
||||
VirtualProtect(slot, 8, old, &ignored);
|
||||
BbscMod::GuardSlot(slot_offset, reinterpret_cast<u64>(wrapper),
|
||||
reinterpret_cast<void**>(&original), name);
|
||||
return true;
|
||||
}
|
||||
Sleep(1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
template <typename Fn>
|
||||
Fn ReadSlot(u64 slot_offset) {
|
||||
const u64 value = *reinterpret_cast<const u64*>(BbscMod::EbootAddress() + slot_offset);
|
||||
if (value == 0 || (value >= BbscMod::EbootAddress() && value < BbscMod::EbootAddress() + EbootSize)) {
|
||||
return nullptr;
|
||||
}
|
||||
return reinterpret_cast<Fn>(value);
|
||||
}
|
||||
|
||||
// Made on the first peer: the emulator starts its P2P transport when the game needs it.
|
||||
// Made on the first peer: the emulator starts its P2P transport when the game needs it. The slots
|
||||
// are read here, not at start-up (review 2026-10-11): the official shadPS4 links the imports again
|
||||
// after BBSC starts, and these slots are not wrapped, so they always hold the emulator's current
|
||||
// functions.
|
||||
bool OpenSocket() {
|
||||
const auto open = ReadSlot<SocketFn>(SlotSocket);
|
||||
const auto bind = ReadSlot<BindFn>(SlotBind);
|
||||
const auto setsockopt = ReadSlot<SetsockoptFn>(SlotSetsockopt);
|
||||
const auto close = ReadSlot<SocketCloseFn>(SlotSocketClose);
|
||||
if (open == nullptr || bind == nullptr || setsockopt == nullptr) {
|
||||
LOG_WARNING(Mod, "P2P channel: the game's socket imports are not filled - retrying");
|
||||
return false;
|
||||
}
|
||||
const s32 s = open("BBSC channel", OrbisAfInet, OrbisSockDgramP2P, 0);
|
||||
if (s < 0) {
|
||||
LOG_WARNING(Mod, "P2P channel: sceNetSocket failed ({:#x}) - retrying", static_cast<u32>(s));
|
||||
return false;
|
||||
}
|
||||
OrbisSockaddrIn bind_to{};
|
||||
bind_to.sin_len = sizeof(bind_to);
|
||||
bind_to.sin_family = OrbisAfInet;
|
||||
bind_to.sin_vport = static_cast<u16>((ChannelVport >> 8) | (ChannelVport << 8));
|
||||
if (const s32 r = bind(s, &bind_to, sizeof(bind_to)); r < 0) {
|
||||
LOG_WARNING(Mod, "P2P channel: bind to vport {} failed ({:#x}) - retrying", ChannelVport,
|
||||
static_cast<u32>(r));
|
||||
if (close != nullptr) {
|
||||
close(s);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const s32 on = 1;
|
||||
setsockopt(s, OrbisSolSocket, OrbisSoNbio, &on, sizeof(on));
|
||||
channel_socket = s;
|
||||
LOG_INFO(Mod, "P2P channel: socket {} on vport {} - BBSC packets go to the players of the "
|
||||
"session directly",
|
||||
s, ChannelVport);
|
||||
return true;
|
||||
}
|
||||
|
||||
DWORD WINAPI ChannelThread(void*) {
|
||||
std::vector<u8> buffer(MaxDatagram);
|
||||
s64 next_open_ms = 0;
|
||||
int failures = 0;
|
||||
s64 next_report_ms = NowMs() + 60'000;
|
||||
for (;;) {
|
||||
const s32 s = channel_socket;
|
||||
if (s < 0) {
|
||||
bool any = false;
|
||||
{
|
||||
std::scoped_lock lock{peers_mutex};
|
||||
any = !peers.empty();
|
||||
}
|
||||
if (any && NowMs() >= next_open_ms && failures < 10 && !OpenSocket()) {
|
||||
next_open_ms = NowMs() + 5'000;
|
||||
if (++failures == 10) {
|
||||
LOG_ERROR(Mod, "P2P channel: no socket after 10 tries - channel off for this run");
|
||||
}
|
||||
}
|
||||
Sleep(100);
|
||||
continue;
|
||||
}
|
||||
bool got_any = false;
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
OrbisSockaddrIn from{};
|
||||
u32 from_len = sizeof(from);
|
||||
const s32 r = original_recvfrom(s, buffer.data(), buffer.size(), OrbisMsgDontwait,
|
||||
&from, &from_len);
|
||||
if (r <= 0) {
|
||||
break;
|
||||
}
|
||||
got_any = true;
|
||||
++received_count;
|
||||
NotePeer(from.sin_addr, from.sin_port, "BBSC packet from it");
|
||||
BbscMod::RelayDispatch(std::vector<u8>(buffer.begin(), buffer.begin() + r));
|
||||
}
|
||||
if (NowMs() >= next_report_ms) {
|
||||
next_report_ms = NowMs() + 60'000;
|
||||
const u32 sent = sent_count.exchange(0);
|
||||
const u32 received = received_count.exchange(0);
|
||||
if (sent != 0 || received != 0) {
|
||||
LOG_INFO(Mod, "P2P channel: last minute {} packet(s) sent, {} received", sent,
|
||||
received);
|
||||
}
|
||||
}
|
||||
Sleep(got_any ? 1 : 5);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace BbscMod {
|
||||
|
||||
bool P2PChannelEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
// One relay frame (envelope included, no length prefix) to every live peer. Returns the peer count.
|
||||
u32 P2PChannelSend(const void* data, u32 size) {
|
||||
const s32 s = channel_socket;
|
||||
if (!enabled || s < 0 || size > MaxDatagram) {
|
||||
return 0;
|
||||
}
|
||||
std::vector<u64> live;
|
||||
{
|
||||
std::scoped_lock lock{peers_mutex};
|
||||
const s64 now = NowMs();
|
||||
for (auto it = peers.begin(); it != peers.end();) {
|
||||
if (now - it->second.seen_ms > PeerKeepMs) {
|
||||
it = peers.erase(it);
|
||||
} else {
|
||||
live.push_back(it->first);
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
u32 count = 0;
|
||||
for (const u64 key : live) {
|
||||
OrbisSockaddrIn to{};
|
||||
to.sin_len = sizeof(to);
|
||||
to.sin_family = OrbisAfInet;
|
||||
to.sin_addr = static_cast<u32>(key >> 16);
|
||||
to.sin_port = static_cast<u16>(key);
|
||||
to.sin_vport = static_cast<u16>((ChannelVport >> 8) | (ChannelVport << 8));
|
||||
if (original_sendto(s, data, size, OrbisMsgDontwait, &to, sizeof(to)) > 0) {
|
||||
++count;
|
||||
}
|
||||
}
|
||||
sent_count += count;
|
||||
return count;
|
||||
}
|
||||
|
||||
u32 P2PChannelPeerCount() {
|
||||
std::scoped_lock lock{peers_mutex};
|
||||
const s64 now = NowMs();
|
||||
u32 count = 0;
|
||||
for (const auto& [key, peer] : peers) {
|
||||
count += now - peer.seen_ms <= PeerKeepMs ? 1 : 0;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
void StartP2PChannel() {
|
||||
if (const char* env = std::getenv("SHADPS4_BBSC_P2P_CHANNEL");
|
||||
env != nullptr && (env[0] == '0' || env[0] == 'f' || env[0] == 'F')) {
|
||||
LOG_INFO(Mod, "P2P channel: off (switch)");
|
||||
return;
|
||||
}
|
||||
if (!Swap<SendtoFn>(SlotSendto, &Sendto, original_sendto, "Sendto") ||
|
||||
!Swap<RecvfromFn>(SlotRecvfrom, &Recvfrom, original_recvfrom, "Recvfrom")) {
|
||||
LOG_ERROR(Mod, "P2P channel: an import slot was never filled - channel off");
|
||||
return;
|
||||
}
|
||||
Swap<ConnectionStatusFn>(SlotConnectionStatus, &ConnectionStatus, original_connection_status,
|
||||
"ConnectionStatus");
|
||||
enabled = true;
|
||||
if (HANDLE thread = CreateThread(nullptr, 0, ChannelThread, nullptr, 0, nullptr)) {
|
||||
CloseHandle(thread);
|
||||
}
|
||||
LOG_INFO(Mod, "P2P channel: ready (vport {}), waiting for the players of a session",
|
||||
ChannelVport);
|
||||
}
|
||||
|
||||
} // namespace BbscMod
|
||||
@@ -0,0 +1,228 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// BBSC Discord status: "Playing BBSC - In Central Yharnam - Hosting (2 of 5)", or the boss during a
|
||||
// boss fight. Talks to the Discord app's local pipe (\\.\pipe\discord-ipc-N) itself - no Discord
|
||||
// library: a frame is {u32 opcode, u32 length, JSON}; opcode 0 = handshake with the application ID,
|
||||
// opcode 1 = a command (SET_ACTIVITY). The game state comes from bloodborne_re.cpp's game tick
|
||||
// (GetBbscPresence). Off with the Mods tab switch "Discord status" (SHADPS4_BBSC_DISCORD=0).
|
||||
// No Discord running = nothing happens; it tries again every 20 s.
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include <fmt/format.h>
|
||||
|
||||
#include "core/bloodborne_re.h"
|
||||
|
||||
namespace BbscMod {
|
||||
void LogPlain(const char* text);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char* DiscordApplicationId = "1554935855674691645"; // "BBSC" (CLS_AMG)
|
||||
|
||||
bool DiscordStatusEnabled() {
|
||||
const char* value = std::getenv("SHADPS4_BBSC_DISCORD");
|
||||
return value == nullptr || (std::string{value} != "0" && std::string{value} != "false");
|
||||
}
|
||||
|
||||
std::string Json(const std::string& text) {
|
||||
std::string out = "\"";
|
||||
for (const char c : text) {
|
||||
if (c == '"' || c == '\\') {
|
||||
out += '\\';
|
||||
out += c;
|
||||
} else if (static_cast<unsigned char>(c) < 0x20) {
|
||||
out += ' ';
|
||||
} else {
|
||||
out += c;
|
||||
}
|
||||
}
|
||||
return out + '"';
|
||||
}
|
||||
|
||||
class DiscordPipe {
|
||||
public:
|
||||
~DiscordPipe() {
|
||||
Close();
|
||||
}
|
||||
|
||||
bool Connect() {
|
||||
for (int i = 0; i < 10; ++i) {
|
||||
const std::wstring name = L"\\\\.\\pipe\\discord-ipc-" + std::to_wstring(i);
|
||||
pipe = CreateFileW(name.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING,
|
||||
0, nullptr);
|
||||
if (pipe != INVALID_HANDLE_VALUE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (pipe == INVALID_HANDLE_VALUE) {
|
||||
return false;
|
||||
}
|
||||
if (!Send(0, fmt::format("{{\"v\":1,\"client_id\":\"{}\"}}", DiscordApplicationId))) {
|
||||
Close();
|
||||
return false;
|
||||
}
|
||||
return Drain(true);
|
||||
}
|
||||
|
||||
bool Send(std::uint32_t opcode, const std::string& json) {
|
||||
if (pipe == INVALID_HANDLE_VALUE) {
|
||||
return false;
|
||||
}
|
||||
std::string frame(8, '\0');
|
||||
const auto length = static_cast<std::uint32_t>(json.size());
|
||||
std::memcpy(frame.data(), &opcode, 4);
|
||||
std::memcpy(frame.data() + 4, &length, 4);
|
||||
frame += json;
|
||||
DWORD written = 0;
|
||||
return WriteFile(pipe, frame.data(), static_cast<DWORD>(frame.size()), &written, nullptr) &&
|
||||
written == frame.size();
|
||||
}
|
||||
|
||||
// Reads whatever Discord sent (answers, READY) so the pipe never fills. wait = block for one
|
||||
// frame (the handshake answer). False = the pipe is gone or Discord closed it (opcode 2).
|
||||
bool Drain(bool wait) {
|
||||
for (;;) {
|
||||
DWORD available = 0;
|
||||
if (!PeekNamedPipe(pipe, nullptr, 0, nullptr, &available, nullptr)) {
|
||||
Close();
|
||||
return false;
|
||||
}
|
||||
if (available < 8 && !wait) {
|
||||
return true;
|
||||
}
|
||||
std::uint32_t header[2]{};
|
||||
DWORD got = 0;
|
||||
if (!ReadFile(pipe, header, 8, &got, nullptr) || got != 8) {
|
||||
Close();
|
||||
return false;
|
||||
}
|
||||
std::string body(header[1], '\0');
|
||||
if (header[1] != 0 &&
|
||||
(!ReadFile(pipe, body.data(), header[1], &got, nullptr) || got != header[1])) {
|
||||
Close();
|
||||
return false;
|
||||
}
|
||||
if (header[0] == 2) { // CLOSE: wrong application ID or Discord shutting down
|
||||
BbscMod::LogPlain(("Discord status: Discord closed the connection: " + body).c_str());
|
||||
Close();
|
||||
return false;
|
||||
}
|
||||
wait = false;
|
||||
}
|
||||
}
|
||||
|
||||
bool Connected() const {
|
||||
return pipe != INVALID_HANDLE_VALUE;
|
||||
}
|
||||
|
||||
void Close() {
|
||||
if (pipe != INVALID_HANDLE_VALUE) {
|
||||
CloseHandle(pipe);
|
||||
pipe = INVALID_HANDLE_VALUE;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
HANDLE pipe = INVALID_HANDLE_VALUE;
|
||||
};
|
||||
|
||||
std::string ActivityJson(const Core::Bloodborne::BbscPresence& p, std::int64_t game_start_s) {
|
||||
std::string details;
|
||||
std::string state;
|
||||
std::string large_text = "Bloodborne Seamless Co-op";
|
||||
std::int64_t start = game_start_s;
|
||||
if (!p.in_game) {
|
||||
details = "At the title screen";
|
||||
} else if (!p.boss.empty()) {
|
||||
details = "Boss fight: " + p.boss;
|
||||
large_text = "In " + p.area;
|
||||
start = p.boss_since_s;
|
||||
} else {
|
||||
details = "In " + p.area;
|
||||
}
|
||||
if (p.in_game) {
|
||||
state = p.role == 1 ? "Hosting" : p.role == 2 ? "Co-op guest" : "Solo hunter";
|
||||
}
|
||||
std::string activity = "{\"details\":" + Json(details);
|
||||
if (!state.empty()) {
|
||||
activity += ",\"state\":" + Json(state);
|
||||
}
|
||||
activity += fmt::format(",\"timestamps\":{{\"start\":{}}}", start);
|
||||
activity += ",\"assets\":{\"large_image\":\"bbsc\",\"large_text\":" + Json(large_text) + "}";
|
||||
if (p.in_game && p.role != 0) {
|
||||
activity += fmt::format(",\"party\":{{\"id\":\"bbsc-{}\",\"size\":[{},{}]}}",
|
||||
GetCurrentProcessId(), p.hunters, p.max_hunters);
|
||||
}
|
||||
activity += "}";
|
||||
return activity;
|
||||
}
|
||||
|
||||
DWORD WINAPI DiscordThread(LPVOID) {
|
||||
const std::int64_t game_start_s = std::chrono::duration_cast<std::chrono::seconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch())
|
||||
.count();
|
||||
DiscordPipe discord;
|
||||
std::string sent;
|
||||
std::uint64_t nonce = 0;
|
||||
auto next_try = std::chrono::steady_clock::now();
|
||||
bool logged_connect = false;
|
||||
for (;;) {
|
||||
std::this_thread::sleep_for(std::chrono::seconds(2));
|
||||
if (!discord.Connected()) {
|
||||
if (std::chrono::steady_clock::now() < next_try) {
|
||||
continue;
|
||||
}
|
||||
if (!discord.Connect()) {
|
||||
next_try = std::chrono::steady_clock::now() + std::chrono::seconds(20);
|
||||
continue;
|
||||
}
|
||||
if (!logged_connect) {
|
||||
BbscMod::LogPlain("Discord status: connected to Discord");
|
||||
logged_connect = true;
|
||||
}
|
||||
sent.clear();
|
||||
}
|
||||
if (!discord.Drain(false)) {
|
||||
continue;
|
||||
}
|
||||
const std::string activity = ActivityJson(Core::Bloodborne::GetBbscPresence(), game_start_s);
|
||||
if (activity == sent) {
|
||||
continue;
|
||||
}
|
||||
const std::string command = fmt::format(
|
||||
"{{\"cmd\":\"SET_ACTIVITY\",\"args\":{{\"pid\":{},\"activity\":{}}},\"nonce\":\"{}\"}}",
|
||||
GetCurrentProcessId(), activity, ++nonce);
|
||||
if (discord.Send(1, command)) {
|
||||
sent = activity;
|
||||
} else {
|
||||
discord.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace BbscMod {
|
||||
|
||||
void StartDiscordStatus() {
|
||||
if (!DiscordStatusEnabled()) {
|
||||
LogPlain("Discord status: off (Mods tab)");
|
||||
return;
|
||||
}
|
||||
if (HANDLE thread = CreateThread(nullptr, 0, DiscordThread, nullptr, 0, nullptr)) {
|
||||
CloseHandle(thread);
|
||||
LogPlain("Discord status: on - shows when the Discord app is running");
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace BbscMod
|
||||
@@ -0,0 +1,260 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// BBSC mod: Core::InstallGuestCodeHook for the DLL. The same trampoline as the emulator's
|
||||
// (core/cpu_patches.cpp): save every register and YMM into a GuestRegisterSnapshot, call the C++
|
||||
// hook with it, restore the (possibly changed) snapshot, run the copied instructions, jump back.
|
||||
//
|
||||
// Where the trampolines live: the emulator maps an 8 MB read/write/execute area right after every
|
||||
// module's image for its own patches (core/module.cpp, TrampolineSize) and fills it from the
|
||||
// start. The DLL takes the last 1 MB of the eboot's area - inside the 2 GB reach of a 5-byte jmp
|
||||
// from any game instruction, and checked to be mapped, executable and still all zero before use.
|
||||
// The image is wherever bbsc_mod.cpp found it (EbootAddress). If that area is not there or not
|
||||
// free (another emulator build), the DLL maps its own 1 MB within reach after (or before) the image.
|
||||
//
|
||||
// Not done here (the emulator checks them with its instruction decoder): that the copied bytes are
|
||||
// whole instructions without RIP-relative operands. Every site BBSC hooks is a fixed, byte-checked
|
||||
// instruction sequence that the emulator build has already installed the same way.
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
|
||||
#include <xbyak/xbyak.h>
|
||||
|
||||
#include "core/cpu_patches.h"
|
||||
|
||||
namespace BbscMod {
|
||||
void LogPlain(const char* text);
|
||||
u64 EbootAddress();
|
||||
}
|
||||
|
||||
namespace Core {
|
||||
namespace {
|
||||
|
||||
using namespace Xbyak::util;
|
||||
|
||||
constexpr u64 EbootAlignedSize = 0x056D4000ULL;
|
||||
constexpr u64 EmulatorTrampolineSize = 8ULL << 20;
|
||||
constexpr u64 OurSize = 1ULL << 20;
|
||||
constexpr u64 JumpReach = 0x7000'0000ULL; // inside a rel32 jump's 2 GB from anywhere in the image
|
||||
|
||||
std::mutex hook_mutex;
|
||||
Xbyak::CodeGenerator* generator = nullptr;
|
||||
bool area_checked = false;
|
||||
bool area_ok = false;
|
||||
|
||||
// The last 1 MB of the emulator's trampoline area after the image, when it is mapped RWX and unused.
|
||||
bool EmulatorAreaFree(u64 start) {
|
||||
MEMORY_BASIC_INFORMATION info{};
|
||||
if (VirtualQuery(reinterpret_cast<void*>(start), &info, sizeof(info)) == 0 ||
|
||||
info.State != MEM_COMMIT ||
|
||||
(info.Protect & (PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY)) == 0 ||
|
||||
reinterpret_cast<u64>(info.BaseAddress) + info.RegionSize < start + OurSize) {
|
||||
return false;
|
||||
}
|
||||
const auto* bytes = reinterpret_cast<const u8*>(start);
|
||||
for (u64 i = 0; i < OurSize; ++i) {
|
||||
if (bytes[i] != 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Our own 1 MB, 64 KB-aligned, within jump reach of the whole image.
|
||||
u64 MapOwnArea(u64 image) {
|
||||
const u64 image_end = image + EbootAlignedSize + EmulatorTrampolineSize;
|
||||
for (u64 at = (image_end + 0xFFFF) & ~0xFFFFULL; at < image + JumpReach; at += 0x10000) {
|
||||
if (void* got = VirtualAlloc(reinterpret_cast<void*>(at), OurSize, MEM_RESERVE | MEM_COMMIT,
|
||||
PAGE_EXECUTE_READWRITE)) {
|
||||
return reinterpret_cast<u64>(got);
|
||||
}
|
||||
}
|
||||
const u64 low = image > JumpReach - EbootAlignedSize ? image + EbootAlignedSize - JumpReach
|
||||
: 0x10000;
|
||||
for (u64 at = (image - OurSize) & ~0xFFFFULL; at >= low && at < image; at -= 0x10000) {
|
||||
if (void* got = VirtualAlloc(reinterpret_cast<void*>(at), OurSize, MEM_RESERVE | MEM_COMMIT,
|
||||
PAGE_EXECUTE_READWRITE)) {
|
||||
return reinterpret_cast<u64>(got);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool CheckArea() {
|
||||
if (area_checked) {
|
||||
return area_ok;
|
||||
}
|
||||
area_checked = true;
|
||||
const u64 image = BbscMod::EbootAddress();
|
||||
if (image == 0) {
|
||||
BbscMod::LogPlain("Hook area: the game image was not found - no hooks");
|
||||
return false;
|
||||
}
|
||||
u64 start = image + EbootAlignedSize + EmulatorTrampolineSize - OurSize;
|
||||
if (!EmulatorAreaFree(start)) {
|
||||
start = MapOwnArea(image);
|
||||
if (start == 0) {
|
||||
BbscMod::LogPlain("Hook area: no free memory within reach of the game image - no hooks");
|
||||
return false;
|
||||
}
|
||||
BbscMod::LogPlain("Hook area: the emulator's area after the image is not usable - BBSC "
|
||||
"mapped its own");
|
||||
}
|
||||
generator = new Xbyak::CodeGenerator(OurSize, reinterpret_cast<void*>(start));
|
||||
area_ok = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WriteJump(u8* site, const void* target, size_t length) {
|
||||
DWORD old = 0;
|
||||
if (!VirtualProtect(site, length, PAGE_EXECUTE_READWRITE, &old)) {
|
||||
return false;
|
||||
}
|
||||
std::array<u8, 32> bytes{};
|
||||
const s64 rel = reinterpret_cast<s64>(target) - reinterpret_cast<s64>(site + 5);
|
||||
bytes[0] = 0xE9;
|
||||
const s32 rel32 = static_cast<s32>(rel);
|
||||
std::memcpy(bytes.data() + 1, &rel32, 4);
|
||||
for (size_t i = 5; i < length; ++i) {
|
||||
bytes[i] = 0x90;
|
||||
}
|
||||
std::memcpy(site, bytes.data(), length);
|
||||
DWORD ignored = 0;
|
||||
VirtualProtect(site, length, old, &ignored);
|
||||
FlushInstructionCache(GetCurrentProcess(), site, length);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool InstallGuestCodeHook(void* address, std::span<const u8> expected_instructions, u64 tag,
|
||||
GuestCodeHook hook, const u8* skip_flag) {
|
||||
constexpr size_t NearJumpBytes = 5;
|
||||
constexpr size_t GuestRedZoneBytes = 128;
|
||||
constexpr size_t SnapshotOffset = 0;
|
||||
constexpr size_t ScratchR11Offset = sizeof(GuestRegisterSnapshot);
|
||||
constexpr size_t ScratchRflagsOffset = ScratchR11Offset + sizeof(u64);
|
||||
constexpr size_t FrameReserve =
|
||||
((ScratchRflagsOffset + sizeof(u64) + GuestRedZoneBytes) + 15) & ~size_t{15};
|
||||
|
||||
auto* code = static_cast<u8*>(address);
|
||||
if (hook == nullptr || expected_instructions.size() < NearJumpBytes ||
|
||||
expected_instructions.size() > 32) {
|
||||
return false;
|
||||
}
|
||||
std::scoped_lock lock{hook_mutex};
|
||||
if (!CheckArea()) {
|
||||
return false;
|
||||
}
|
||||
if (std::memcmp(expected_instructions.data(), code, expected_instructions.size()) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
auto& trampoline = *generator;
|
||||
const size_t trampoline_offset = trampoline.getSize();
|
||||
const auto* trampoline_start = trampoline.getCurr();
|
||||
const auto snapshot = [](size_t field_offset) {
|
||||
return qword[rsp + SnapshotOffset + field_offset];
|
||||
};
|
||||
|
||||
try {
|
||||
trampoline.lea(rsp, ptr[rsp - FrameReserve]);
|
||||
trampoline.mov(qword[rsp + ScratchR11Offset], r11);
|
||||
trampoline.pushfq();
|
||||
trampoline.pop(r11);
|
||||
trampoline.mov(qword[rsp + ScratchRflagsOffset], r11);
|
||||
trampoline.mov(r11, rsp);
|
||||
trampoline.and_(rsp, -16);
|
||||
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rax)), rax);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rbx)), rbx);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rcx)), rcx);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rdx)), rdx);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rsi)), rsi);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rdi)), rdi);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rbp)), rbp);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r8)), r8);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r9)), r9);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r10)), r10);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r12)), r12);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r13)), r13);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r14)), r14);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r15)), r15);
|
||||
trampoline.mov(rax, qword[r11 + ScratchR11Offset]);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r11)), rax);
|
||||
trampoline.mov(rax, qword[r11 + ScratchRflagsOffset]);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rflags)), rax);
|
||||
trampoline.lea(rax, ptr[r11 + FrameReserve]);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rsp)), rax);
|
||||
trampoline.stmxcsr(dword[rsp + SnapshotOffset + offsetof(GuestRegisterSnapshot, mxcsr)]);
|
||||
for (size_t index = 0; index < 16; ++index) {
|
||||
const size_t offset = SnapshotOffset + offsetof(GuestRegisterSnapshot, ymm) +
|
||||
index * sizeof(std::array<u8, 32>);
|
||||
trampoline.vmovdqu(yword[rsp + offset], Xbyak::Ymm(static_cast<int>(index)));
|
||||
}
|
||||
|
||||
// The hook is Windows code: 32 bytes of shadow space and the MS x64 arguments are not
|
||||
// what a PS4_SYSV_ABI function expects, so it is called the SysV way (rdi, rsi), exactly
|
||||
// as the emulator does. A 32-byte gap below the snapshot keeps either convention safe.
|
||||
trampoline.mov(r12, rsp);
|
||||
trampoline.sub(rsp, 32);
|
||||
trampoline.mov(rdi, tag);
|
||||
trampoline.lea(rsi, ptr[r12 + SnapshotOffset]);
|
||||
trampoline.mov(rax, reinterpret_cast<u64>(hook));
|
||||
trampoline.call(rax);
|
||||
trampoline.mov(rsp, r12);
|
||||
|
||||
for (size_t index = 0; index < 16; ++index) {
|
||||
const size_t offset = SnapshotOffset + offsetof(GuestRegisterSnapshot, ymm) +
|
||||
index * sizeof(std::array<u8, 32>);
|
||||
trampoline.vmovdqu(Xbyak::Ymm(static_cast<int>(index)), yword[rsp + offset]);
|
||||
}
|
||||
trampoline.ldmxcsr(dword[rsp + SnapshotOffset + offsetof(GuestRegisterSnapshot, mxcsr)]);
|
||||
trampoline.mov(rbx, snapshot(offsetof(GuestRegisterSnapshot, rbx)));
|
||||
trampoline.mov(rcx, snapshot(offsetof(GuestRegisterSnapshot, rcx)));
|
||||
trampoline.mov(rdx, snapshot(offsetof(GuestRegisterSnapshot, rdx)));
|
||||
trampoline.mov(rsi, snapshot(offsetof(GuestRegisterSnapshot, rsi)));
|
||||
trampoline.mov(rdi, snapshot(offsetof(GuestRegisterSnapshot, rdi)));
|
||||
trampoline.mov(rbp, snapshot(offsetof(GuestRegisterSnapshot, rbp)));
|
||||
trampoline.mov(r8, snapshot(offsetof(GuestRegisterSnapshot, r8)));
|
||||
trampoline.mov(r9, snapshot(offsetof(GuestRegisterSnapshot, r9)));
|
||||
trampoline.mov(r10, snapshot(offsetof(GuestRegisterSnapshot, r10)));
|
||||
trampoline.mov(r11, snapshot(offsetof(GuestRegisterSnapshot, r11)));
|
||||
trampoline.mov(r12, snapshot(offsetof(GuestRegisterSnapshot, r12)));
|
||||
trampoline.mov(r13, snapshot(offsetof(GuestRegisterSnapshot, r13)));
|
||||
trampoline.mov(r14, snapshot(offsetof(GuestRegisterSnapshot, r14)));
|
||||
trampoline.mov(r15, snapshot(offsetof(GuestRegisterSnapshot, r15)));
|
||||
trampoline.mov(rax, snapshot(offsetof(GuestRegisterSnapshot, rflags)));
|
||||
trampoline.push(rax);
|
||||
trampoline.popfq();
|
||||
trampoline.mov(rax, snapshot(offsetof(GuestRegisterSnapshot, rax)));
|
||||
trampoline.mov(rsp, snapshot(offsetof(GuestRegisterSnapshot, rsp)));
|
||||
Xbyak::Label run_original;
|
||||
if (skip_flag != nullptr) {
|
||||
// Function entry only: a plain ret skips the whole function (see cpu_patches.h).
|
||||
trampoline.push(rax);
|
||||
trampoline.mov(rax, reinterpret_cast<u64>(skip_flag));
|
||||
trampoline.cmp(byte[rax], 0);
|
||||
trampoline.mov(byte[rax], 0);
|
||||
trampoline.pop(rax);
|
||||
trampoline.je(run_original, Xbyak::CodeGenerator::LabelType::T_NEAR);
|
||||
trampoline.ret();
|
||||
}
|
||||
trampoline.L(run_original);
|
||||
trampoline.db(expected_instructions.data(), expected_instructions.size());
|
||||
trampoline.jmp(code + expected_instructions.size(), Xbyak::CodeGenerator::LabelType::T_NEAR);
|
||||
} catch (const Xbyak::Error& error) {
|
||||
trampoline.setSize(trampoline_offset);
|
||||
BbscMod::LogPlain(error.what());
|
||||
return false;
|
||||
}
|
||||
FlushInstructionCache(GetCurrentProcess(), trampoline_start,
|
||||
trampoline.getSize() - trampoline_offset);
|
||||
return WriteJump(code, trampoline_start, expected_instructions.size());
|
||||
}
|
||||
|
||||
} // namespace Core
|
||||
@@ -0,0 +1,744 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// BBSC mod: the game's HTTP calls. In the emulator build, core/libraries/network/http.cpp
|
||||
// changes Bloodborne's summon traffic on its way out (Wozzardman's seamless route plus BBSC's
|
||||
// connect-first and stale-record cleanup). A mod cannot reach that code, so the DLL does it one
|
||||
// step earlier: it swaps the eboot's import slots (GOT entries the emulator filled at load) of
|
||||
// the libSceHttp functions the game calls for its own wrappers, which change the request and pass
|
||||
// it on to the emulator's function.
|
||||
//
|
||||
// Import slots, CUSA03173 01.09 (tools/gamedata/eboot_imports.py, BBSC offsets):
|
||||
// 0x053E5618 sceHttpCreateConnectionWithURL 0x053E5620 sceHttpCreateRequestWithURL
|
||||
// 0x053E55E0 sceHttpAddRequestHeader 0x053E5628 sceHttpSetRequestContentLength
|
||||
// 0x053E5670 sceHttpSendRequest 0x053E5648 sceHttpDeleteRequest
|
||||
// 0x053E5668 sceHttpReadData
|
||||
//
|
||||
// What the wrappers do:
|
||||
// - URLs: the entries of host_overrides.json (same file and format as the emulator's) are
|
||||
// applied here too, so a stock emulator reaches the BBSC game server and summon broker.
|
||||
// - /summon_messenger/*: header X-ShadPS4-Bloodborne-Seamless: 1 (the broker's seamless mode).
|
||||
// - summon search/request (a host looking for signs): the host's placement header, as the
|
||||
// emulator sent it, AND the same text to every other player through the BBSC relay - the game
|
||||
// does not import sceHttpGetAllResponseHeaders, so the guest cannot read it from the broker's
|
||||
// reply; the relay brings it instead (bbsc_relay.cpp -> BbscMod::OnHostPlacement).
|
||||
// - summon create (a guest's sign): the AreaId becomes the host's map once a host's placement
|
||||
// is known (connect-first), and the first seamless create of this run sends a forced delete
|
||||
// for the same SessionId/UserId first (stale broker record after a crash).
|
||||
// - a claim the broker delivered with a sign that never became a join, and a session that
|
||||
// ended, re-arm that delete: the next sign starts clean (see "Stuck claims" below).
|
||||
// Not done: the emulator's 3 s "fast sign" re-posts. They replaced the game's own reply; extra
|
||||
// posts from here would take the broker's claim themselves and the game would never join.
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#include <winhttp.h>
|
||||
#else
|
||||
#include "linux/bbsc_win32.h" // bbport
|
||||
#endif
|
||||
#ifndef _WIN32
|
||||
#include "core/memory.h" // bbport: SessionState
|
||||
#endif
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <thread>
|
||||
#include <mutex>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "common/logging/log.h"
|
||||
#include "common/types.h"
|
||||
#include "core/bloodborne_re.h"
|
||||
|
||||
namespace BbscMod {
|
||||
void RelaySendPlacement(const std::string& placement);
|
||||
u64 EbootAddress(); // bbsc_mod.cpp: where the game image is (0 until found)
|
||||
void GuardSlot(u64 slot_offset, u64 wrapper, void** original, const char* name); // bbsc_mod.cpp
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr u64 EbootSize = 0x056D4000ULL;
|
||||
constexpr u64 SlotCreateConnection = 0x053E5618;
|
||||
constexpr u64 SlotCreateRequest = 0x053E5620;
|
||||
constexpr u64 SlotAddHeader = 0x053E55E0;
|
||||
constexpr u64 SlotSetContentLength = 0x053E5628;
|
||||
constexpr u64 SlotSend = 0x053E5670;
|
||||
constexpr u64 SlotDelete = 0x053E5648;
|
||||
constexpr u64 SlotReadData = 0x053E5668;
|
||||
// A claim delivered with our sign is followed by a join (session state 4) within seconds when it
|
||||
// works; one that has not after this long is a dead claim.
|
||||
constexpr s64 ClaimJoinWaitMs = 20'000;
|
||||
constexpr s64 PlacementStaleMs = 20'000;
|
||||
|
||||
using CreateConnectionFn = int PS4_SYSV_ABI (*)(int, const char*, bool);
|
||||
using CreateRequestFn = int PS4_SYSV_ABI (*)(int, s32, const char*, u64);
|
||||
using AddHeaderFn = int PS4_SYSV_ABI (*)(int, const char*, const char*, s32);
|
||||
using SetContentLengthFn = int PS4_SYSV_ABI (*)(int, u64);
|
||||
using SendFn = int PS4_SYSV_ABI (*)(int, const void*, u64);
|
||||
using DeleteFn = int PS4_SYSV_ABI (*)(int);
|
||||
using ReadDataFn = int PS4_SYSV_ABI (*)(int, void*, u64);
|
||||
|
||||
CreateConnectionFn original_create_connection = nullptr;
|
||||
CreateRequestFn original_create_request = nullptr;
|
||||
AddHeaderFn original_add_header = nullptr;
|
||||
SetContentLengthFn original_set_content_length = nullptr;
|
||||
SendFn original_send = nullptr;
|
||||
DeleteFn original_delete = nullptr;
|
||||
ReadDataFn original_read_data = nullptr;
|
||||
|
||||
std::mutex state_mutex;
|
||||
std::map<int, std::string> request_urls; // reqId -> URL the request really goes to
|
||||
std::vector<std::pair<std::string, std::string>> overrides; // URL prefix -> replacement
|
||||
std::atomic<bool> stale_cleared{false};
|
||||
std::atomic<s64> placement_received_ms{0};
|
||||
// 5-player test 2026-10-12: every player polls /summon_messenger/get, waiting guests too, so every
|
||||
// guest sent its own placement as "a host looking for signs". The other waiting guests switched
|
||||
// their sign's map between the real host (Dream) and a guest in Central Yharnam, and the broker
|
||||
// hid every sign outside the searcher's map (Preparing) - nobody past the 3rd was summoned.
|
||||
// Only a player without a sign of its own (or a host, state 3) sends the placement now, and a
|
||||
// waiting guest follows one host until that host goes quiet.
|
||||
std::atomic<s64> own_sign_created_ms{0};
|
||||
std::atomic<s64> own_sign_deleted_ms{0};
|
||||
constexpr s64 OwnSignMs = 60'000; // the game posts its sign again about every 30 s
|
||||
std::atomic<u32> placement_sender{0};
|
||||
std::atomic<s64> claim_delivered_ms{0}; // our sign's reply carried a host's claim (0 = none open)
|
||||
std::atomic<bool> was_in_session{false};
|
||||
|
||||
s64 NowMs() {
|
||||
return static_cast<s64>(GetTickCount64());
|
||||
}
|
||||
|
||||
// ---- host_overrides.json ------------------------------------------------------------------------
|
||||
|
||||
#ifdef _WIN32
|
||||
std::string ReadFile(const std::wstring& path) {
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
std::stringstream text;
|
||||
text << in.rdbuf();
|
||||
return text.str();
|
||||
}
|
||||
|
||||
// {"prefix": "replacement", ...} - flat string pairs, as JOIN-HOST writes them.
|
||||
void LoadOverrides() {
|
||||
wchar_t exe[MAX_PATH]{};
|
||||
GetModuleFileNameW(nullptr, exe, MAX_PATH);
|
||||
std::wstring dir{exe};
|
||||
const std::wstring exe_name = dir.substr(dir.find_last_of(L"\\/") + 1);
|
||||
dir = dir.substr(0, dir.find_last_of(L"\\/") + 1);
|
||||
// bbhost sends the game's URLs to its own server ([online] host) itself. shadPS4's
|
||||
// host_overrides.json on the same PC pointed them at another server (2026-10-12: game API on
|
||||
// the shadPS4 server, NP sessions on bbhost's - nothing matched). BBSC_URL_OVERRIDES=0 does the
|
||||
// same for any host.
|
||||
const char* switch_env = std::getenv("BBSC_URL_OVERRIDES");
|
||||
if (_wcsnicmp(exe_name.c_str(), L"bbhost", 6) == 0 || (switch_env && switch_env[0] == '0')) {
|
||||
LOG_INFO(Mod, "HTTP: URL overrides off ({}) - the host sends the game's URLs itself",
|
||||
switch_env && switch_env[0] == '0' ? "BBSC_URL_OVERRIDES=0" : "bbhost");
|
||||
return;
|
||||
}
|
||||
// The emulator's own rule: a "user" folder in the folder it was started in wins (portable).
|
||||
wchar_t cwd[MAX_PATH]{};
|
||||
GetCurrentDirectoryW(MAX_PATH, cwd);
|
||||
std::vector<std::wstring> candidates{std::wstring(cwd) + L"\\user\\host_overrides.json",
|
||||
dir + L"user\\host_overrides.json"};
|
||||
if (const wchar_t* appdata = _wgetenv(L"APPDATA")) {
|
||||
candidates.push_back(std::wstring(appdata) + L"\\shadPS4\\host_overrides.json");
|
||||
}
|
||||
for (const auto& path : candidates) {
|
||||
const std::string json = ReadFile(path);
|
||||
std::vector<std::string> strings;
|
||||
for (size_t at = json.find('"'); at != std::string::npos; at = json.find('"', at)) {
|
||||
const size_t end = json.find('"', at + 1);
|
||||
if (end == std::string::npos) {
|
||||
break;
|
||||
}
|
||||
strings.push_back(json.substr(at + 1, end - at - 1));
|
||||
at = end + 1;
|
||||
}
|
||||
for (size_t i = 0; i + 1 < strings.size(); i += 2) {
|
||||
overrides.emplace_back(strings[i], strings[i + 1]);
|
||||
}
|
||||
if (!overrides.empty()) {
|
||||
LOG_INFO(Mod, "HTTP: {} URL override(s) from host_overrides.json", overrides.size());
|
||||
return;
|
||||
}
|
||||
}
|
||||
LOG_INFO(Mod, "HTTP: no host_overrides.json - URLs unchanged");
|
||||
}
|
||||
#else
|
||||
// bbport: the online module applies the user folder's host_overrides itself, to every URL the game
|
||||
// sends; applying them here as well would rewrite them twice.
|
||||
void LoadOverrides() {
|
||||
LOG_INFO(Mod, "HTTP: URL overrides left to bbport's online module");
|
||||
}
|
||||
#endif
|
||||
|
||||
// 0.0.0.0 is a server's "listen on everything", never an address to connect to. A game server
|
||||
// started with it as its shadnet address (seen 2026-10-01 under Nucleus Co-op with
|
||||
// NoDreamForHunter's server) puts it into ss.info, and every summon sign call then fails with
|
||||
// "Could not establish connection". It can only have meant this PC.
|
||||
std::string FixAnyAddress(std::string text) {
|
||||
for (const char* scheme : {"http://0.0.0.0", "https://0.0.0.0"}) {
|
||||
const std::string prefix = scheme;
|
||||
if (text.rfind(prefix, 0) == 0 &&
|
||||
(text.size() == prefix.size() || text[prefix.size()] == ':' ||
|
||||
text[prefix.size()] == '/')) {
|
||||
static bool logged = false;
|
||||
if (!logged) {
|
||||
logged = true;
|
||||
LOG_WARNING(Mod, "HTTP: the server list sends the game to 0.0.0.0 ({}) - using "
|
||||
"127.0.0.1 instead. Start the game server with a real shadnet "
|
||||
"address.",
|
||||
text);
|
||||
}
|
||||
return text.replace(prefix.size() - 7, 7, "127.0.0.1");
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
std::string Override(const char* url) {
|
||||
std::string text = FixAnyAddress(url != nullptr ? url : "");
|
||||
for (const auto& [prefix, replacement] : overrides) {
|
||||
if (text.rfind(prefix, 0) == 0) {
|
||||
return replacement + text.substr(prefix.size());
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// ---- Summon paths -------------------------------------------------------------------------------
|
||||
|
||||
bool Contains(const std::string& url, const char* part) {
|
||||
return url.find(part) != std::string::npos;
|
||||
}
|
||||
bool IsSummon(const std::string& url) {
|
||||
return Contains(url, "/summon_messenger/");
|
||||
}
|
||||
bool IsSummonCreate(const std::string& url) {
|
||||
return Contains(url, "/summon_messenger/create");
|
||||
}
|
||||
bool IsSummonDelete(const std::string& url) {
|
||||
return Contains(url, "/summon_messenger/delete");
|
||||
}
|
||||
|
||||
bool IsSummonSearch(const std::string& url) {
|
||||
return Contains(url, "/summon_messenger/request") || Contains(url, "/summon_messenger/get");
|
||||
}
|
||||
|
||||
// Public servers (2026-10-09): The Hunter's Dream (and the other community servers) let mods in as
|
||||
// long as the game's API calls are not changed. On them BBSC adds no headers, rewrites no sign and
|
||||
// sends no deletes - the seamless broker extras exist only on a BBSC host's shadnet-p2p server.
|
||||
bool IsPublicServer(const std::string& url) {
|
||||
static constexpr std::array<const char*, 4> Hosts{"thehuntersdream.com", "nodreamforthehunter.com",
|
||||
"shadps4.net", "scej-network.jp"};
|
||||
const size_t scheme = url.find("://");
|
||||
const size_t start = scheme == std::string::npos ? 0 : scheme + 3;
|
||||
const std::string host = url.substr(start, url.find_first_of(":/", start) - start);
|
||||
for (const char* known : Hosts) {
|
||||
const size_t len = std::strlen(known);
|
||||
if (host.size() >= len && host.compare(host.size() - len, len, known) == 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// "AreaId":<digits> -> the host's map, when connect-first has one. Returns true if changed.
|
||||
bool RewriteAreaId(std::string& body) {
|
||||
const auto host_map = Core::Bloodborne::GetBbscConnectFirstAreaId();
|
||||
if (!host_map) {
|
||||
return false;
|
||||
}
|
||||
const std::string key = "\"AreaId\":";
|
||||
const size_t at = body.find(key);
|
||||
if (at == std::string::npos) {
|
||||
return false;
|
||||
}
|
||||
const size_t begin = at + key.size();
|
||||
size_t end = begin;
|
||||
while (end < body.size() && body[end] >= '0' && body[end] <= '9') {
|
||||
++end;
|
||||
}
|
||||
const std::string original = body.substr(begin, end - begin);
|
||||
const std::string replacement = std::to_string(*host_map);
|
||||
if (end == begin || original == replacement) {
|
||||
return false;
|
||||
}
|
||||
body.replace(begin, end - begin, replacement);
|
||||
LOG_INFO(Mod, "HTTP: connect-first - sign AreaId {} -> {}", original, replacement);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string JsonString(const std::string& body, const char* key) {
|
||||
const std::string needle = std::string("\"") + key + "\":\"";
|
||||
const size_t at = body.find(needle);
|
||||
if (at == std::string::npos) {
|
||||
return {};
|
||||
}
|
||||
const size_t begin = at + needle.size();
|
||||
const size_t end = body.find('"', begin);
|
||||
return end == std::string::npos ? std::string{} : body.substr(begin, end - begin);
|
||||
}
|
||||
|
||||
std::string JsonNumber(const std::string& body, const char* key) {
|
||||
const std::string needle = std::string("\"") + key + "\":";
|
||||
const size_t at = body.find(needle);
|
||||
if (at == std::string::npos) {
|
||||
return {};
|
||||
}
|
||||
size_t end = at + needle.size();
|
||||
while (end < body.size() && (std::isdigit(static_cast<unsigned char>(body[end])) || body[end] == '-')) {
|
||||
++end;
|
||||
}
|
||||
return body.substr(at + needle.size(), end - at - needle.size());
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
// One plain POST with WinHTTP (the stale-record delete). Returns the status code, 0 on failure.
|
||||
DWORD Post(const std::string& url, const std::string& body) {
|
||||
std::wstring wide(url.begin(), url.end());
|
||||
URL_COMPONENTS parts{sizeof(parts)};
|
||||
wchar_t host[256]{};
|
||||
wchar_t path[2048]{};
|
||||
parts.lpszHostName = host;
|
||||
parts.dwHostNameLength = 256;
|
||||
parts.lpszUrlPath = path;
|
||||
parts.dwUrlPathLength = 2048;
|
||||
if (!WinHttpCrackUrl(wide.c_str(), 0, 0, &parts)) {
|
||||
return 0;
|
||||
}
|
||||
DWORD status = 0;
|
||||
HINTERNET session = WinHttpOpen(L"BBSC", WINHTTP_ACCESS_TYPE_NO_PROXY, nullptr, nullptr, 0);
|
||||
HINTERNET connect = session ? WinHttpConnect(session, host, parts.nPort, 0) : nullptr;
|
||||
HINTERNET request =
|
||||
connect ? WinHttpOpenRequest(connect, L"POST", path, nullptr, nullptr, nullptr,
|
||||
parts.nScheme == INTERNET_SCHEME_HTTPS ? WINHTTP_FLAG_SECURE : 0)
|
||||
: nullptr;
|
||||
if (request != nullptr) {
|
||||
WinHttpSetTimeouts(request, 1000, 1000, 1000, 1500);
|
||||
const wchar_t* headers =
|
||||
L"Content-Type: application/json\r\nX-ShadPS4-Bloodborne-Seamless: 1\r\n";
|
||||
if (WinHttpSendRequest(request, headers, static_cast<DWORD>(-1),
|
||||
const_cast<char*>(body.data()), static_cast<DWORD>(body.size()),
|
||||
static_cast<DWORD>(body.size()), 0) &&
|
||||
WinHttpReceiveResponse(request, nullptr)) {
|
||||
DWORD size = sizeof(status);
|
||||
WinHttpQueryHeaders(request, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
|
||||
nullptr, &status, &size, nullptr);
|
||||
}
|
||||
}
|
||||
for (HINTERNET h : {request, connect, session}) {
|
||||
if (h != nullptr) {
|
||||
WinHttpCloseHandle(h);
|
||||
}
|
||||
}
|
||||
return status;
|
||||
}
|
||||
#else
|
||||
// bbport: one plain HTTP POST over a socket (the stale-record delete goes to the summon broker,
|
||||
// shadNet's WebAPI, over http). Returns the status code, 0 on failure; https is not done here.
|
||||
DWORD Post(const std::string& url, const std::string& body) {
|
||||
constexpr std::string_view Scheme = "http://";
|
||||
if (url.rfind(Scheme, 0) != 0) {
|
||||
return 0;
|
||||
}
|
||||
const std::string rest = url.substr(Scheme.size());
|
||||
const size_t slash = rest.find('/');
|
||||
const std::string authority = rest.substr(0, slash);
|
||||
const std::string path = slash == std::string::npos ? "/" : rest.substr(slash);
|
||||
std::string host = authority, port = "80";
|
||||
if (const size_t colon = authority.rfind(':'); colon != std::string::npos) {
|
||||
host = authority.substr(0, colon);
|
||||
port = authority.substr(colon + 1);
|
||||
}
|
||||
addrinfo hints{};
|
||||
hints.ai_family = AF_INET;
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
addrinfo* found = nullptr;
|
||||
if (getaddrinfo(host.c_str(), port.c_str(), &hints, &found) != 0 || found == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
const SOCKET s = socket(found->ai_family, found->ai_socktype, found->ai_protocol);
|
||||
const timeval timeout{1, 500'000};
|
||||
if (s != INVALID_SOCKET) {
|
||||
setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
|
||||
setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
|
||||
}
|
||||
const bool connected =
|
||||
s != INVALID_SOCKET && connect(s, found->ai_addr, found->ai_addrlen) == 0;
|
||||
freeaddrinfo(found);
|
||||
if (!connected) {
|
||||
if (s != INVALID_SOCKET) {
|
||||
closesocket(s);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
const std::string request = "POST " + path + " HTTP/1.0\r\nHost: " + authority +
|
||||
"\r\nContent-Type: application/json\r\n"
|
||||
"X-ShadPS4-Bloodborne-Seamless: 1\r\nContent-Length: " +
|
||||
std::to_string(body.size()) + "\r\nConnection: close\r\n\r\n" + body;
|
||||
DWORD status = 0;
|
||||
if (send(s, request.data(), request.size(), MSG_NOSIGNAL) == static_cast<ssize_t>(request.size())) {
|
||||
char reply[64]{};
|
||||
const ssize_t got = recv(s, reply, sizeof(reply) - 1, 0);
|
||||
// "HTTP/1.x NNN ..."
|
||||
if (got > 12 && std::strncmp(reply, "HTTP/", 5) == 0) {
|
||||
status = static_cast<DWORD>(std::atoi(reply + 9));
|
||||
}
|
||||
}
|
||||
closesocket(s);
|
||||
return status;
|
||||
}
|
||||
#endif
|
||||
|
||||
// The broker keeps Preparing/Claimed/Delivered records 15 minutes and Bloodborne reuses its
|
||||
// SessionId/UserId after a restart, so the first seamless sign of this run removes any old one.
|
||||
void ClearStaleRecord(const std::string& url, const std::string& body) {
|
||||
if (stale_cleared.exchange(true)) {
|
||||
return;
|
||||
}
|
||||
const std::string session = JsonString(body, "SessionId");
|
||||
const std::string user = JsonNumber(body, "UserId");
|
||||
if (session.empty() || user.empty()) {
|
||||
return;
|
||||
}
|
||||
std::string removal_url = url;
|
||||
removal_url.replace(removal_url.find("/summon_messenger/create"),
|
||||
std::strlen("/summon_messenger/create"), "/summon_messenger/delete");
|
||||
const std::string removal = "{\"MessageId\":\"SummonDataRemoveRequest\",\"SessionId\":\"" +
|
||||
session + "\",\"UserId\":" + user + ",\"Force\":true}";
|
||||
// It must finish before the new sign is created (the delete names the same session), so it
|
||||
// stays on this thread - the game's - with short timeouts (2026-10-08: up to 14 s before).
|
||||
const DWORD status = Post(removal_url, removal);
|
||||
LOG_INFO(Mod, "HTTP: stale summon cleanup for session {} user {}: status {}", session, user,
|
||||
status);
|
||||
}
|
||||
|
||||
// ---- Stuck claims (2026-10-06) -------------------------------------------------------------------
|
||||
// 4-player Nucleus test: one guest never joined and another waited minutes. In seamless mode the
|
||||
// broker keeps a claimed sign 15 minutes (Preparing/Claimed/Delivered) and, every time that guest
|
||||
// posts a sign again, hands back the OLD claim - from a host room that is gone or a join the game
|
||||
// already gave up on. The host's searches list the same record, and its new claims are refused as
|
||||
// conflicts. Nothing frees it but the 15-minute expiry or a forced delete, and the forced delete
|
||||
// only ran once per game start. Now it runs again (on the next sign) when:
|
||||
// - the broker delivered a claim and no join followed within ClaimJoinWaitMs, or
|
||||
// - we were in a session (joining, guest or host) and are back to posting signs.
|
||||
s32 SessionState() {
|
||||
const u64 manager = *reinterpret_cast<const u64*>(BbscMod::EbootAddress() + 0x05540290);
|
||||
#ifndef _WIN32
|
||||
// bbport: the game's memory map instead of VirtualQuery.
|
||||
if (manager >= 0x10000 && Core::Memory::Instance()->IsValidMapping(manager + 0x124, 4)) {
|
||||
return *reinterpret_cast<const s32*>(manager + 0x124);
|
||||
}
|
||||
return -1;
|
||||
#else
|
||||
MEMORY_BASIC_INFORMATION info{};
|
||||
constexpr DWORD Readable = PAGE_READONLY | PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READ |
|
||||
PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY;
|
||||
if (manager >= 0x10000 && VirtualQuery(reinterpret_cast<void*>(manager + 0x124), &info,
|
||||
sizeof(info)) != 0 &&
|
||||
info.State == MEM_COMMIT && (info.Protect & Readable) != 0 &&
|
||||
(info.Protect & (PAGE_GUARD | PAGE_NOACCESS)) == 0) {
|
||||
return *reinterpret_cast<const s32*>(manager + 0x124);
|
||||
}
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
void WatchSessionForStaleClaims() {
|
||||
const s32 state = SessionState();
|
||||
if (state == 3 || state == 4 || state == 6) {
|
||||
was_in_session = true;
|
||||
if (claim_delivered_ms.exchange(0) != 0) {
|
||||
LOG_INFO(Mod, "HTTP: the delivered claim became a join (session state {})", state);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state != 0) {
|
||||
return;
|
||||
}
|
||||
if (was_in_session.exchange(false)) {
|
||||
stale_cleared = false;
|
||||
LOG_INFO(Mod, "HTTP: session over - the next sign first clears our old broker record");
|
||||
}
|
||||
const s64 delivered = claim_delivered_ms.load();
|
||||
if (delivered != 0 && NowMs() - delivered > ClaimJoinWaitMs) {
|
||||
claim_delivered_ms = 0;
|
||||
stale_cleared = false;
|
||||
LOG_WARNING(Mod, "HTTP: a host's claim was delivered {} s ago and never became a join - "
|
||||
"the next sign first clears it",
|
||||
(NowMs() - delivered) / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- The wrappers -------------------------------------------------------------------------------
|
||||
|
||||
int PS4_SYSV_ABI CreateConnectionWithURL(int template_id, const char* url, bool keepalive) {
|
||||
const std::string target = Override(url);
|
||||
return original_create_connection(template_id, target.c_str(), keepalive);
|
||||
}
|
||||
|
||||
int PS4_SYSV_ABI CreateRequestWithURL(int connection, s32 method, const char* url, u64 length) {
|
||||
const std::string target = Override(url);
|
||||
const int request = original_create_request(connection, method, target.c_str(), length);
|
||||
if (request > 0) {
|
||||
std::scoped_lock lock{state_mutex};
|
||||
request_urls[request] = target;
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
// A slot's value when it holds an emulator function (outside the eboot), else 0.
|
||||
u64 CurrentSlot(u64 slot_offset) {
|
||||
const u64 base = BbscMod::EbootAddress();
|
||||
const u64 value = *reinterpret_cast<const volatile u64*>(base + slot_offset);
|
||||
return value != 0 && (value < base || value >= base + EbootSize) ? value : 0;
|
||||
}
|
||||
|
||||
int PS4_SYSV_ABI SendRequest(int request, const void* data, u64 size) {
|
||||
std::string url;
|
||||
{
|
||||
std::scoped_lock lock{state_mutex};
|
||||
if (const auto it = request_urls.find(request); it != request_urls.end()) {
|
||||
url = it->second;
|
||||
}
|
||||
}
|
||||
WatchSessionForStaleClaims();
|
||||
if (!IsSummon(url)) {
|
||||
return original_send(request, data, size);
|
||||
}
|
||||
if (IsPublicServer(url)) {
|
||||
static std::atomic<bool> logged{false};
|
||||
if (!logged.exchange(true)) {
|
||||
LOG_INFO(Mod, "HTTP: summon requests go to a public server - sent unchanged (no "
|
||||
"seamless headers, connect-first or cleanup)");
|
||||
}
|
||||
return original_send(request, data, size);
|
||||
}
|
||||
// Not wrapped, so the slots always hold the emulator's current functions: read them again at
|
||||
// use (review 2026-10-11 - the official shadPS4 links the imports again after start-up).
|
||||
if (const u64 add = CurrentSlot(SlotAddHeader); add != 0) {
|
||||
original_add_header = reinterpret_cast<AddHeaderFn>(add);
|
||||
}
|
||||
if (const u64 length = CurrentSlot(SlotSetContentLength); length != 0) {
|
||||
original_set_content_length = reinterpret_cast<SetContentLengthFn>(length);
|
||||
}
|
||||
if (original_add_header == nullptr || original_set_content_length == nullptr) {
|
||||
return original_send(request, data, size); // (review 2026-10-08) the slots never filled
|
||||
}
|
||||
original_add_header(request, "X-ShadPS4-Bloodborne-Seamless", "1", 0);
|
||||
if (IsSummonDelete(url)) {
|
||||
own_sign_deleted_ms = NowMs();
|
||||
}
|
||||
if (IsSummonSearch(url)) {
|
||||
const s64 created = own_sign_created_ms.load();
|
||||
const bool own_sign_out = SessionState() != 3 && created != 0 &&
|
||||
created > own_sign_deleted_ms.load() &&
|
||||
NowMs() - created < OwnSignMs;
|
||||
if (own_sign_out) {
|
||||
static std::atomic<s64> last_logged{0};
|
||||
if (NowMs() - last_logged.load() > 60'000) {
|
||||
last_logged = NowMs();
|
||||
LOG_INFO(Mod, "HTTP: sign search while our own sign is out - no host placement "
|
||||
"sent (we are waiting, not hosting)");
|
||||
}
|
||||
} else if (const auto placement = Core::Bloodborne::GetSeamlessHostPlacementHeader()) {
|
||||
original_add_header(request, "X-ShadPS4-Bloodborne-Host-Placement", placement->c_str(),
|
||||
0);
|
||||
BbscMod::RelaySendPlacement(*placement);
|
||||
}
|
||||
return original_send(request, data, size);
|
||||
}
|
||||
if (IsSummonCreate(url)) {
|
||||
own_sign_created_ms = NowMs();
|
||||
}
|
||||
if (IsSummonCreate(url) && data != nullptr && size != 0) {
|
||||
std::string body(static_cast<const char*>(data), static_cast<size_t>(size));
|
||||
ClearStaleRecord(url, body);
|
||||
// A host's placement that has not been refreshed for a while is a host that stopped
|
||||
// looking: forget it, as the emulator did when a create reply carried none.
|
||||
const s64 received = placement_received_ms.load();
|
||||
if (received != 0 && NowMs() - received > PlacementStaleMs) {
|
||||
placement_received_ms = 0;
|
||||
placement_sender = 0;
|
||||
Core::Bloodborne::ClearSeamlessHostPlacementHeader();
|
||||
}
|
||||
if (RewriteAreaId(body)) {
|
||||
original_set_content_length(request, body.size());
|
||||
// The emulator copies the body during the call, so a local buffer is fine.
|
||||
return original_send(request, body.data(), body.size());
|
||||
}
|
||||
}
|
||||
return original_send(request, data, size);
|
||||
}
|
||||
|
||||
// A sign's reply is a bare {"ResKind":0,"MessageId":"SummonDataCreateResponse"} unless the broker
|
||||
// delivers a host's claim with it, which adds the claim's members (the host's UserId among them).
|
||||
int PS4_SYSV_ABI ReadData(int request, void* data, u64 size) {
|
||||
const int read = original_read_data(request, data, size);
|
||||
if (read > 0 && data != nullptr) {
|
||||
std::string url;
|
||||
{
|
||||
std::scoped_lock lock{state_mutex};
|
||||
if (const auto it = request_urls.find(request); it != request_urls.end()) {
|
||||
url = it->second;
|
||||
}
|
||||
}
|
||||
// Public servers (2026-10-10: a host's summon never reached the guest on The Hunter's
|
||||
// Dream): every summon reply that is not a bare create answer goes into the log.
|
||||
if (IsSummon(url) && IsPublicServer(url)) {
|
||||
const std::string_view reply(static_cast<const char*>(data), static_cast<size_t>(read));
|
||||
const size_t q = url.find('?');
|
||||
const size_t slash = url.rfind('/', q);
|
||||
const std::string what = url.substr(slash + 1, q == std::string::npos ? q : q - slash - 1);
|
||||
const bool bare = reply.find("\"SummonDataCreateResponse\"") != std::string_view::npos &&
|
||||
reply.size() <= 60;
|
||||
static std::atomic<int> bare_logged{0};
|
||||
if (what == "get") {
|
||||
// Sign lists hold other players' signs: only their size.
|
||||
LOG_INFO(Mod, "HTTP: summon reply (get, {} bytes)", read);
|
||||
} else if (!bare || bare_logged.fetch_add(1) < 2) {
|
||||
std::string text(reply.substr(0, 400));
|
||||
for (char& c : text) {
|
||||
if (static_cast<unsigned char>(c) < 0x20) {
|
||||
c = '.';
|
||||
}
|
||||
}
|
||||
LOG_INFO(Mod, "HTTP: summon reply ({}, {} bytes): {}", what, read, text);
|
||||
}
|
||||
}
|
||||
if (IsSummonCreate(url)) {
|
||||
const std::string_view reply(static_cast<const char*>(data), static_cast<size_t>(read));
|
||||
if (reply.find("\"UserId\"") != std::string_view::npos &&
|
||||
claim_delivered_ms.exchange(NowMs()) == 0) {
|
||||
LOG_INFO(Mod, "HTTP: a host's claim came with our sign ({} bytes) - waiting for the "
|
||||
"join",
|
||||
read);
|
||||
}
|
||||
}
|
||||
}
|
||||
return read;
|
||||
}
|
||||
|
||||
int PS4_SYSV_ABI DeleteRequest(int request) {
|
||||
{
|
||||
std::scoped_lock lock{state_mutex};
|
||||
request_urls.erase(request);
|
||||
}
|
||||
return original_delete(request);
|
||||
}
|
||||
|
||||
// Waits until the emulator has filled the slot (a host address, outside the eboot), then swaps
|
||||
// it. The original goes into `original` BEFORE the slot points at the wrapper (review 2026-10-08:
|
||||
// the game could call the wrapper before the original was stored). False if the slot never filled.
|
||||
template <typename Fn>
|
||||
bool Swap(u64 slot_offset, Fn wrapper, Fn& original, const char* name) {
|
||||
auto* slot = reinterpret_cast<u64*>(BbscMod::EbootAddress() + slot_offset);
|
||||
for (int i = 0; i < 60'000; ++i) {
|
||||
const u64 value = *slot;
|
||||
if (value != 0 && (value < BbscMod::EbootAddress() || value >= BbscMod::EbootAddress() + EbootSize)) {
|
||||
original = reinterpret_cast<Fn>(value);
|
||||
DWORD old = 0;
|
||||
VirtualProtect(slot, 8, PAGE_READWRITE, &old);
|
||||
*slot = reinterpret_cast<u64>(wrapper);
|
||||
DWORD ignored = 0;
|
||||
VirtualProtect(slot, 8, old, &ignored);
|
||||
BbscMod::GuardSlot(slot_offset, reinterpret_cast<u64>(wrapper),
|
||||
reinterpret_cast<void**>(&original), name);
|
||||
return true;
|
||||
}
|
||||
Sleep(1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// The two slots read, not swapped: waits for them as Swap does (they are filled at the same time).
|
||||
u64 WaitForSlot(u64 slot_offset) {
|
||||
const auto* slot = reinterpret_cast<const u64*>(BbscMod::EbootAddress() + slot_offset);
|
||||
for (int i = 0; i < 60'000; ++i) {
|
||||
const u64 value = *slot;
|
||||
if (value != 0 && (value < BbscMod::EbootAddress() || value >= BbscMod::EbootAddress() + EbootSize)) {
|
||||
return value;
|
||||
}
|
||||
Sleep(1);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace BbscMod {
|
||||
|
||||
// The relay asks before connecting (bbsc_relay.cpp): public servers run no BBSC relay.
|
||||
bool IsPublicServerAddress(const std::string& url_or_host) {
|
||||
return IsPublicServer(url_or_host);
|
||||
}
|
||||
|
||||
// A host's placement from the relay: what the broker's reply header carried in the emulator
|
||||
// build. Used only by a player who is not in a session yet (a guest waiting to be summoned).
|
||||
void OnHostPlacement(const std::string& placement, u32 sender) {
|
||||
const s32 state = SessionState();
|
||||
if (state == 3 || state == 4 || state == 6) {
|
||||
return; // hosting, joining or already a guest
|
||||
}
|
||||
// One host at a time: another sender's placement is taken only once ours has gone quiet.
|
||||
const u32 current = placement_sender.load();
|
||||
const s64 received = placement_received_ms.load();
|
||||
if (current != 0 && current != sender && received != 0 &&
|
||||
NowMs() - received <= PlacementStaleMs) {
|
||||
static std::atomic<s64> last_logged{0};
|
||||
if (NowMs() - last_logged.load() > 30'000) {
|
||||
last_logged = NowMs();
|
||||
LOG_INFO(Mod, "HTTP: placement from another player {:08x} ignored - following host "
|
||||
"{:08x}",
|
||||
sender, current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Core::Bloodborne::SetSeamlessHostPlacementHeader(placement)) {
|
||||
if (placement_sender.exchange(sender) != sender) {
|
||||
LOG_INFO(Mod, "HTTP: following host {:08x} for the sign's map", sender);
|
||||
}
|
||||
// "1,<map>,x,y,z,heading,area": the map is logged whenever it changes (2026-10-07: the
|
||||
// third player's sign stayed on a map the host had left; only the first one was logged).
|
||||
static std::string last_map;
|
||||
const size_t a = placement.find(',');
|
||||
const size_t b = a == std::string::npos ? a : placement.find(',', a + 1);
|
||||
const std::string map = b == std::string::npos ? std::string{} : placement.substr(a + 1, b - a - 1);
|
||||
if (placement_received_ms.exchange(NowMs()) == 0 || map != last_map) {
|
||||
LOG_INFO(Mod, "HTTP: a host is looking for signs - placement {}", placement);
|
||||
}
|
||||
last_map = map;
|
||||
}
|
||||
}
|
||||
|
||||
void InstallHttpWrappers() {
|
||||
LoadOverrides();
|
||||
original_add_header = reinterpret_cast<AddHeaderFn>(WaitForSlot(SlotAddHeader));
|
||||
original_set_content_length =
|
||||
reinterpret_cast<SetContentLengthFn>(WaitForSlot(SlotSetContentLength));
|
||||
Swap<CreateConnectionFn>(SlotCreateConnection, &CreateConnectionWithURL,
|
||||
original_create_connection, "CreateConnectionWithURL");
|
||||
Swap<CreateRequestFn>(SlotCreateRequest, &CreateRequestWithURL, original_create_request,
|
||||
"CreateRequestWithURL");
|
||||
Swap<SendFn>(SlotSend, &SendRequest, original_send, "SendRequest");
|
||||
Swap<DeleteFn>(SlotDelete, &DeleteRequest, original_delete, "DeleteRequest");
|
||||
Swap<ReadDataFn>(SlotReadData, &ReadData, original_read_data, "ReadData");
|
||||
const bool ok = original_create_connection && original_create_request && original_add_header &&
|
||||
original_set_content_length && original_send && original_delete &&
|
||||
original_read_data;
|
||||
LOG_INFO(Mod, "HTTP: game's HTTP calls {} (URL overrides, seamless summon, connect-first, stale "
|
||||
"cleanup)",
|
||||
ok ? "wrapped" : "NOT wrapped - an import slot was never filled");
|
||||
}
|
||||
|
||||
} // namespace BbscMod
|
||||
@@ -0,0 +1,394 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// bbsc-launch.exe - starts shadPS4 with the BBSC mod DLL loaded into it.
|
||||
//
|
||||
// bbsc-launch.exe [--emu <shadPS4.exe>] [--dll <bbsc.dll>] [--window left|right] [--choose]
|
||||
// [--cwd <folder>] [shadPS4 arguments...]
|
||||
//
|
||||
// --cwd: the folder the emulator starts in (default: the emulator's own folder). shadPS4 uses the
|
||||
// "user" folder of that folder when there is one (portable), else %APPDATA%\shadPS4 - a portable
|
||||
// Qt launcher keeps "user" next to the launcher, not next to the emulator version it runs.
|
||||
//
|
||||
// Double-clicked with no arguments: the first time it asks (Windows file picker) for
|
||||
// Bloodborne's eboot.bin and, if there is no shadPS4.exe next to it, for the emulator; the
|
||||
// choices are kept in bbsc-launch.ini next to it. --choose asks again.
|
||||
//
|
||||
// Defaults: shadPS4.exe and bbsc.dll next to this launcher. Every other argument is passed to
|
||||
// shadPS4 unchanged (e.g. --game <path to eboot.bin>). The emulator is created suspended, the DLL
|
||||
// is loaded into it by a remote LoadLibraryW, and only then is the emulator let go - so the mod is
|
||||
// in place before the game's first instruction. The launcher waits for the emulator to close and
|
||||
// returns its exit code.
|
||||
|
||||
#include <windows.h>
|
||||
#include <commdlg.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
std::wstring HereFile(const wchar_t* name) {
|
||||
wchar_t path[MAX_PATH]{};
|
||||
GetModuleFileNameW(nullptr, path, MAX_PATH);
|
||||
std::wstring dir{path};
|
||||
const size_t slash = dir.find_last_of(L"\\/");
|
||||
return (slash == std::wstring::npos ? L"" : dir.substr(0, slash + 1)) + name;
|
||||
}
|
||||
|
||||
std::wstring Quote(const std::wstring& arg) {
|
||||
if (!arg.empty() && arg.find_first_of(L" \t\"") == std::wstring::npos) {
|
||||
return arg;
|
||||
}
|
||||
std::wstring out = L"\"";
|
||||
size_t backslashes = 0;
|
||||
for (const wchar_t c : arg) {
|
||||
if (c == L'\\') {
|
||||
++backslashes;
|
||||
} else if (c == L'"') {
|
||||
out.append(backslashes * 2 + 1, L'\\');
|
||||
out += c;
|
||||
backslashes = 0;
|
||||
continue;
|
||||
} else {
|
||||
backslashes = 0;
|
||||
}
|
||||
out += c;
|
||||
}
|
||||
out.append(backslashes * 2, L'\\');
|
||||
return out + L"\"";
|
||||
}
|
||||
|
||||
struct FindWindowArgs {
|
||||
DWORD pid;
|
||||
HWND found;
|
||||
};
|
||||
|
||||
BOOL CALLBACK FindProcessWindow(HWND window, LPARAM param) {
|
||||
auto* args = reinterpret_cast<FindWindowArgs*>(param);
|
||||
DWORD pid = 0;
|
||||
GetWindowThreadProcessId(window, &pid);
|
||||
if (pid == args->pid && IsWindowVisible(window) && GetWindow(window, GW_OWNER) == nullptr &&
|
||||
GetWindowTextLengthW(window) > 0) {
|
||||
args->found = window;
|
||||
return FALSE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// --window left|right: puts the emulator's window on that half of the main screen once it
|
||||
// appears, and keeps it there for the first seconds (the emulator sizes its window while starting).
|
||||
void PlaceWindow(DWORD pid, const std::wstring& side) {
|
||||
RECT work{};
|
||||
SystemParametersInfoW(SPI_GETWORKAREA, 0, &work, 0);
|
||||
// left / right: half the screen; tl / tr / bl / br: a quarter (4-window tests, 2026-10-07).
|
||||
const bool quarter = side == L"tl" || side == L"tr" || side == L"bl" || side == L"br";
|
||||
const int half = (work.right - work.left) / 2;
|
||||
const bool right = side == L"right" || side == L"tr" || side == L"br";
|
||||
const bool bottom = side == L"bl" || side == L"br";
|
||||
const int full_height = work.bottom - work.top;
|
||||
const int x = right ? work.left + half : work.left;
|
||||
const int y = bottom ? work.top + full_height / 2 : work.top;
|
||||
const int height = quarter ? full_height / 2 : full_height;
|
||||
DWORD placed_at = 0;
|
||||
for (int i = 0; i < 480; ++i) { // up to 2 minutes for the window to appear
|
||||
FindWindowArgs args{pid, nullptr};
|
||||
EnumWindows(FindProcessWindow, reinterpret_cast<LPARAM>(&args));
|
||||
if (args.found != nullptr) {
|
||||
RECT now{};
|
||||
GetWindowRect(args.found, &now);
|
||||
if (now.left != x || now.top != y || now.right - now.left != half ||
|
||||
now.bottom - now.top != height) {
|
||||
ShowWindow(args.found, SW_RESTORE);
|
||||
SetWindowPos(args.found, nullptr, x, y, half, height,
|
||||
SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
}
|
||||
if (placed_at == 0) {
|
||||
placed_at = GetTickCount();
|
||||
} else if (GetTickCount() - placed_at > 15'000) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Sleep(250);
|
||||
}
|
||||
}
|
||||
|
||||
DWORD WINAPI PlaceWindowThread(void* param) {
|
||||
auto* args = static_cast<std::pair<DWORD, std::wstring>*>(param);
|
||||
PlaceWindow(args->first, args->second);
|
||||
delete args;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
// ---- Remembered choices: bbsc-launch.ini next to the launcher (game=..., emulator=...) ---------
|
||||
|
||||
std::wstring IniPath() {
|
||||
return HereFile(L"bbsc-launch.ini");
|
||||
}
|
||||
|
||||
std::wstring LoadSetting(const wchar_t* key) {
|
||||
wchar_t value[MAX_PATH * 2]{};
|
||||
GetPrivateProfileStringW(L"bbsc", key, L"", value, MAX_PATH * 2, IniPath().c_str());
|
||||
return value;
|
||||
}
|
||||
|
||||
void SaveSetting(const wchar_t* key, const std::wstring& value) {
|
||||
WritePrivateProfileStringW(L"bbsc", key, value.c_str(), IniPath().c_str());
|
||||
}
|
||||
|
||||
bool FileExists(const std::wstring& path) {
|
||||
const DWORD attributes = GetFileAttributesW(path.c_str());
|
||||
return !path.empty() && attributes != INVALID_FILE_ATTRIBUTES &&
|
||||
(attributes & FILE_ATTRIBUTE_DIRECTORY) == 0;
|
||||
}
|
||||
|
||||
void Message(const std::wstring& text, UINT icon = MB_ICONINFORMATION) {
|
||||
MessageBoxW(nullptr, text.c_str(), L"BBSC", MB_OK | icon | MB_SETFOREGROUND);
|
||||
}
|
||||
|
||||
// A Windows "open file" window. Empty if the player cancelled.
|
||||
std::wstring PickFile(const wchar_t* title, const wchar_t* filter) {
|
||||
wchar_t path[MAX_PATH]{};
|
||||
OPENFILENAMEW dialog{sizeof(dialog)};
|
||||
dialog.lpstrTitle = title;
|
||||
dialog.lpstrFilter = filter;
|
||||
dialog.lpstrFile = path;
|
||||
dialog.nMaxFile = MAX_PATH;
|
||||
dialog.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR;
|
||||
return GetOpenFileNameW(&dialog) ? std::wstring{path} : std::wstring{};
|
||||
}
|
||||
|
||||
// The folder of eboot.bin should hold sce_sys\param.sfo naming CUSA03173.
|
||||
bool IsBloodborne(const std::wstring& eboot) {
|
||||
const std::wstring sfo =
|
||||
eboot.substr(0, eboot.find_last_of(L"\\/") + 1) + L"sce_sys\\param.sfo";
|
||||
HANDLE file = CreateFileW(sfo.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0,
|
||||
nullptr);
|
||||
if (file == INVALID_HANDLE_VALUE) {
|
||||
return false;
|
||||
}
|
||||
std::string data(64 * 1024, '\0');
|
||||
DWORD read = 0;
|
||||
ReadFile(file, data.data(), static_cast<DWORD>(data.size()), &read, nullptr);
|
||||
CloseHandle(file);
|
||||
data.resize(read);
|
||||
return data.find("CUSA03173") != std::string::npos;
|
||||
}
|
||||
|
||||
std::wstring ChooseGame() {
|
||||
Message(L"Please pick your Bloodborne game file.\n\n"
|
||||
L"It is the file eboot.bin in your Bloodborne folder (the one named CUSA03173).\n"
|
||||
L"You only have to do this once.");
|
||||
for (;;) {
|
||||
const std::wstring picked =
|
||||
PickFile(L"BBSC - pick Bloodborne's eboot.bin", L"eboot.bin\0eboot.bin\0All files\0*.*\0");
|
||||
if (picked.empty()) {
|
||||
return {};
|
||||
}
|
||||
if (IsBloodborne(picked)) {
|
||||
SaveSetting(L"game", picked);
|
||||
return picked;
|
||||
}
|
||||
if (MessageBoxW(nullptr,
|
||||
L"That folder does not look like Bloodborne CUSA03173 (no sce_sys\\param.sfo "
|
||||
L"with CUSA03173 next to it).\n\nUse it anyway?",
|
||||
L"BBSC", MB_YESNO | MB_ICONWARNING | MB_SETFOREGROUND) == IDYES) {
|
||||
SaveSetting(L"game", picked);
|
||||
return picked;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::wstring ChooseEmulator() {
|
||||
Message(L"Please pick the shadPS4.exe you play BBSC with.\n\nYou only have to do this once.");
|
||||
const std::wstring picked =
|
||||
PickFile(L"BBSC - pick shadPS4.exe", L"shadPS4.exe\0*.exe\0All files\0*.*\0");
|
||||
if (!picked.empty()) {
|
||||
SaveSetting(L"emulator", picked);
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
void DefaultSwitch(const wchar_t* name, const wchar_t* value) {
|
||||
if (GetEnvironmentVariableW(name, nullptr, 0) == 0) {
|
||||
SetEnvironmentVariableW(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
int Fail(const wchar_t* what, HANDLE process = nullptr) {
|
||||
const DWORD error = GetLastError();
|
||||
Message(std::wstring(what) + L"\n\n(Windows error " + std::to_wstring(error) + L")",
|
||||
MB_ICONERROR);
|
||||
if (process != nullptr) {
|
||||
TerminateProcess(process, 1);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int wmain(int argc, wchar_t** argv) {
|
||||
std::wstring emulator;
|
||||
std::wstring dll = HereFile(L"bbsc.dll");
|
||||
std::wstring window_side;
|
||||
std::wstring start_dir;
|
||||
bool choose = false;
|
||||
bool no_mod = false; // --no-mod: start the emulator as it is (comparison runs)
|
||||
bool has_game = false;
|
||||
std::vector<std::wstring> passthrough;
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
const std::wstring arg = argv[i];
|
||||
if (arg == L"--choose") {
|
||||
choose = true;
|
||||
} else if (arg == L"--no-mod") {
|
||||
no_mod = true;
|
||||
} else if (arg == L"--emu" && i + 1 < argc) {
|
||||
emulator = argv[++i];
|
||||
} else if (arg == L"--dll" && i + 1 < argc) {
|
||||
dll = argv[++i];
|
||||
} else if (arg == L"--cwd" && i + 1 < argc) {
|
||||
start_dir = argv[++i];
|
||||
} else if (arg == L"--window" && i + 1 < argc) {
|
||||
window_side = argv[++i];
|
||||
} else {
|
||||
has_game = has_game || arg == L"--game" || arg == L"-g";
|
||||
passthrough.push_back(arg);
|
||||
}
|
||||
}
|
||||
// The emulator: --emu, else shadPS4.exe next to the launcher, else the one picked before,
|
||||
// else ask. The game: --game, else the eboot.bin picked before, else ask.
|
||||
// BBSC_EMULATOR: another program to start instead (the bbport Windows build sets its game exe
|
||||
// through BB_PROBE, so bbsc-launch stands in for bb-probe.exe and starts it from here).
|
||||
if (emulator.empty()) {
|
||||
wchar_t from_env[MAX_PATH]{};
|
||||
if (GetEnvironmentVariableW(L"BBSC_EMULATOR", from_env, MAX_PATH) > 0) {
|
||||
emulator = from_env;
|
||||
}
|
||||
}
|
||||
// bbport (Windows): bb-probe.exe gets the prepared game image from bbport's run.py - no
|
||||
// --game, and it runs in the folder run.py started it in (paths relative to the port).
|
||||
const std::wstring emulator_name =
|
||||
emulator.substr(emulator.find_last_of(L"\\/") == std::wstring::npos
|
||||
? 0
|
||||
: emulator.find_last_of(L"\\/") + 1);
|
||||
const bool bbport = _wcsicmp(emulator_name.c_str(), L"bb-probe.exe") == 0;
|
||||
has_game = has_game || bbport;
|
||||
if (emulator.empty()) {
|
||||
emulator = HereFile(L"shadPS4.exe");
|
||||
if (!FileExists(emulator)) {
|
||||
emulator = choose ? std::wstring{} : LoadSetting(L"emulator");
|
||||
if (!FileExists(emulator)) {
|
||||
emulator = ChooseEmulator();
|
||||
if (emulator.empty()) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only shadPS4 takes --game; other hosts (bbport's bb-probe, bbhost) get their own arguments,
|
||||
// and keep our output handles (their launchers log what the game writes).
|
||||
bool foreign = false;
|
||||
{
|
||||
const size_t cut = emulator.find_last_of(L"\\/");
|
||||
const std::wstring name = emulator.substr(cut == std::wstring::npos ? 0 : cut + 1);
|
||||
foreign = _wcsnicmp(name.c_str(), L"shadps4", 7) != 0;
|
||||
has_game = has_game || foreign;
|
||||
}
|
||||
if (!has_game) {
|
||||
std::wstring game = choose ? std::wstring{} : LoadSetting(L"game");
|
||||
if (!FileExists(game)) {
|
||||
game = ChooseGame();
|
||||
if (game.empty()) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
passthrough.push_back(L"--game");
|
||||
passthrough.push_back(game);
|
||||
}
|
||||
// The BBSC game build does all of this itself; with the mod it must not (the mod does it).
|
||||
// Set only if the player did not set them; a build without BBSC ignores them.
|
||||
if (!no_mod) {
|
||||
DefaultSwitch(L"SHADPS4_BBSC_PERSIST", L"0");
|
||||
DefaultSwitch(L"SHADPS4_BLOODBORNE_SEAMLESS_COOP", L"0");
|
||||
DefaultSwitch(L"SHADPS4_BLOODBORNE_RE_TRACE", L"0");
|
||||
}
|
||||
wchar_t full_dll[MAX_PATH]{};
|
||||
if (GetFullPathNameW(dll.c_str(), MAX_PATH, full_dll, nullptr) == 0 ||
|
||||
(!no_mod && GetFileAttributesW(full_dll) == INVALID_FILE_ATTRIBUTES)) {
|
||||
return Fail((L"mod DLL not found: " + dll).c_str());
|
||||
}
|
||||
if (GetFileAttributesW(emulator.c_str()) == INVALID_FILE_ATTRIBUTES) {
|
||||
return Fail((L"emulator not found: " + emulator).c_str());
|
||||
}
|
||||
|
||||
std::wstring command = Quote(emulator);
|
||||
for (const auto& arg : passthrough) {
|
||||
command += L" " + Quote(arg);
|
||||
}
|
||||
std::wstring working_dir = emulator;
|
||||
const size_t slash = working_dir.find_last_of(L"\\/");
|
||||
working_dir = slash == std::wstring::npos ? L"." : working_dir.substr(0, slash);
|
||||
if (!start_dir.empty()) {
|
||||
working_dir = start_dir;
|
||||
} else if (bbport) {
|
||||
wchar_t current[MAX_PATH]{};
|
||||
GetCurrentDirectoryW(MAX_PATH, current);
|
||||
working_dir = current;
|
||||
}
|
||||
|
||||
STARTUPINFOW startup{sizeof(startup)};
|
||||
if (foreign) {
|
||||
startup.dwFlags |= STARTF_USESTDHANDLES;
|
||||
startup.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
|
||||
startup.hStdOutput = GetStdHandle(STD_OUTPUT_HANDLE);
|
||||
startup.hStdError = GetStdHandle(STD_ERROR_HANDLE);
|
||||
}
|
||||
PROCESS_INFORMATION info{};
|
||||
// bbport: its launcher sends bb-probe's output to user\last_run.log through our handles.
|
||||
if (!CreateProcessW(emulator.c_str(), command.data(), nullptr, nullptr, foreign ? TRUE : FALSE,
|
||||
CREATE_SUSPENDED,
|
||||
nullptr, working_dir.c_str(), &startup, &info)) {
|
||||
return Fail(L"could not start the emulator");
|
||||
}
|
||||
|
||||
// Load the DLL in the suspended emulator: its first thread runs the loader's process setup.
|
||||
if (!no_mod) {
|
||||
const size_t bytes = (wcslen(full_dll) + 1) * sizeof(wchar_t);
|
||||
void* remote = VirtualAllocEx(info.hProcess, nullptr, bytes, MEM_COMMIT | MEM_RESERVE,
|
||||
PAGE_READWRITE);
|
||||
if (remote == nullptr || !WriteProcessMemory(info.hProcess, remote, full_dll, bytes, nullptr)) {
|
||||
return Fail(L"could not pass the DLL path to the emulator", info.hProcess);
|
||||
}
|
||||
auto* load_library = reinterpret_cast<LPTHREAD_START_ROUTINE>(
|
||||
GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "LoadLibraryW"));
|
||||
HANDLE loader = CreateRemoteThread(info.hProcess, nullptr, 0, load_library, remote, 0, nullptr);
|
||||
if (loader == nullptr) {
|
||||
return Fail(L"could not load the mod into the emulator", info.hProcess);
|
||||
}
|
||||
WaitForSingleObject(loader, 30'000);
|
||||
DWORD loaded = 0;
|
||||
GetExitCodeThread(loader, &loaded);
|
||||
CloseHandle(loader);
|
||||
VirtualFreeEx(info.hProcess, remote, 0, MEM_RELEASE);
|
||||
if (loaded == 0) {
|
||||
return Fail(L"the emulator refused the mod DLL (LoadLibraryW returned 0)", info.hProcess);
|
||||
}
|
||||
} // !no_mod
|
||||
ResumeThread(info.hThread);
|
||||
CloseHandle(info.hThread);
|
||||
if (window_side == L"left" || window_side == L"right" || window_side == L"tl" ||
|
||||
window_side == L"tr" || window_side == L"bl" || window_side == L"br") {
|
||||
auto* args = new std::pair<DWORD, std::wstring>(info.dwProcessId, window_side);
|
||||
if (HANDLE placer = CreateThread(nullptr, 0, PlaceWindowThread, args, 0, nullptr)) {
|
||||
CloseHandle(placer);
|
||||
}
|
||||
}
|
||||
WaitForSingleObject(info.hProcess, INFINITE);
|
||||
DWORD code = 0;
|
||||
GetExitCodeProcess(info.hProcess, &code);
|
||||
CloseHandle(info.hProcess);
|
||||
return static_cast<int>(code);
|
||||
}
|
||||
@@ -0,0 +1,929 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// BBSC as a mod: a DLL that bbsc-launch.exe injects into shadPS4.exe before it starts.
|
||||
//
|
||||
// The DLL runs in the emulator's process, where the game's memory sits at its guest addresses
|
||||
// (eboot at 0x800000000 in the shadp2p fork; the official shadPS4 nightly of 2026-10-09 loads it
|
||||
// at 0x9B00000 - FindEbootBase looks for it), so it needs nothing from the emulator:
|
||||
// 1. wait until the eboot is in memory and every byte patch site reads back as the bytes that
|
||||
// must be there (all of them before writing any - another game or version is left alone);
|
||||
// 2. write the byte patches (core/bbsc_patch_tables.h, the same list as the emulator build);
|
||||
// 3. install every hook with the emulator build's own code, core/bloodborne_re.cpp, compiled
|
||||
// into this DLL with BBSC_MOD; its emulator calls are answered here and in bbsc_hooks.cpp
|
||||
// (hook trampolines) and bbsc_relay.cpp (BBSC's packets, through the game server);
|
||||
// 4. keep watching: a patch site the emulator wrote back is written again.
|
||||
// Session suspensions and the lamp role toggles come from bloodborne_re.cpp as in the emulator
|
||||
// build (SetPersistentSessionPatches / SetHostLampPatches below).
|
||||
//
|
||||
// Log: logs\bbsc-mod-<date>-<time>-<pid>.log next to the DLL (one per game window, so two
|
||||
// windows on one PC do not overwrite each other); capture files in captures\ next to it.
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <cstdarg>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
|
||||
#include "common/logging/log.h"
|
||||
#include "common/memory_patcher.h"
|
||||
#include "common/path_util.h"
|
||||
#include "core/bbsc_cinematic.h"
|
||||
#include "core/bbsc_patch_tables.h"
|
||||
#include "core/bloodborne_re.h"
|
||||
#include "core/debugger.h"
|
||||
#include "core/libraries/np/np_matching2/np_matching2_mm.h"
|
||||
#include "core/memory.h"
|
||||
|
||||
using namespace Core::Bbsc::PatchTables;
|
||||
|
||||
// Public version (2026-10-11): 0.MINOR.PATCH while testing - PATCH for bug fixes, MINOR for new
|
||||
// features, 1.0.0 the finished release. Keep it equal to "version" in mod.json.
|
||||
#define BBSC_VERSION "0.0.1"
|
||||
|
||||
namespace BbscMod {
|
||||
void LogPlain(const char* text);
|
||||
u64 EbootAddress();
|
||||
void StartRelay();
|
||||
void StartP2PChannel(); // bbsc_channel.cpp
|
||||
bool GameServerIsPublic(std::string* host_out); // bbsc_relay.cpp
|
||||
void InstallHttpWrappers();
|
||||
void StartDiscordStatus();
|
||||
} // namespace BbscMod
|
||||
namespace Core::Bbsc {
|
||||
void InstallPadWrapper();
|
||||
} // namespace Core::Bbsc
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr u64 ForkEbootBase = 0x800000000ULL; // where the shadp2p fork puts it
|
||||
u64 EbootBase = 0; // found by FindEbootBase before anything else uses it
|
||||
constexpr u64 EbootSize = 0x056D4000ULL; // CUSA03173 01.09 image size
|
||||
constexpr DWORD WaitForImageMs = 180'000;
|
||||
constexpr DWORD SettleMs = 20; // let the emulator finish its own load-time patching
|
||||
constexpr DWORD WatchMs = 500;
|
||||
|
||||
HMODULE self_module = nullptr;
|
||||
std::filesystem::path mod_dir;
|
||||
FILE* log_file = nullptr;
|
||||
std::mutex log_mutex;
|
||||
|
||||
void OpenLog() {
|
||||
wchar_t path[MAX_PATH]{};
|
||||
GetModuleFileNameW(self_module, path, MAX_PATH);
|
||||
mod_dir = std::filesystem::path{path}.parent_path();
|
||||
SYSTEMTIME now;
|
||||
GetLocalTime(&now);
|
||||
wchar_t name[96]{};
|
||||
swprintf(name, 96, L"bbsc-mod-%04u%02u%02u-%02u%02u%02u-%lu.log", now.wYear, now.wMonth, now.wDay,
|
||||
now.wHour, now.wMinute, now.wSecond, GetCurrentProcessId());
|
||||
std::error_code ignored;
|
||||
std::filesystem::create_directories(mod_dir / L"logs", ignored);
|
||||
log_file = _wfopen((mod_dir / L"logs" / name).c_str(), L"w");
|
||||
}
|
||||
|
||||
// ---- Byte patches --------------------------------------------------------------------------------
|
||||
|
||||
// VirtualQuery is a kernel call that shares the address-space lock with the emulator's constant
|
||||
// page-protection changes (GPU memory tracking). bloodborne_re.cpp checks every memory read, so
|
||||
// thousands of these per frame took the game to ~1 fps (2026-09-30, two testers). The emulator
|
||||
// build answers the same checks from its own memory map; here each game thread remembers the last
|
||||
// few regions it asked about for a moment.
|
||||
struct CachedRegion {
|
||||
u64 start = 0;
|
||||
u64 end = 0;
|
||||
DWORD state = 0;
|
||||
DWORD protect = 0;
|
||||
u64 stamp_ms = 0;
|
||||
};
|
||||
constexpr u64 RegionCacheMs = 250;
|
||||
constexpr size_t RegionCacheSize = 16;
|
||||
|
||||
bool QueryRegion(u64 address, CachedRegion& out) {
|
||||
thread_local std::array<CachedRegion, RegionCacheSize> cache{};
|
||||
thread_local size_t next = 0;
|
||||
const u64 now = GetTickCount64();
|
||||
for (const CachedRegion& region : cache) {
|
||||
if (address >= region.start && address < region.end &&
|
||||
now - region.stamp_ms <= RegionCacheMs) {
|
||||
out = region;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
MEMORY_BASIC_INFORMATION info{};
|
||||
if (VirtualQuery(reinterpret_cast<void*>(address), &info, sizeof(info)) == 0) {
|
||||
return false;
|
||||
}
|
||||
CachedRegion& slot = cache[next];
|
||||
next = (next + 1) % RegionCacheSize;
|
||||
slot.start = reinterpret_cast<u64>(info.BaseAddress);
|
||||
slot.end = slot.start + info.RegionSize;
|
||||
slot.state = info.State;
|
||||
slot.protect = info.Protect;
|
||||
slot.stamp_ms = now;
|
||||
out = slot;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Readable(u64 address, size_t size) {
|
||||
u64 at = address;
|
||||
const u64 end = address + size;
|
||||
while (at < end) {
|
||||
CachedRegion region;
|
||||
if (!QueryRegion(at, region) || region.state != MEM_COMMIT ||
|
||||
(region.protect & (PAGE_NOACCESS | PAGE_GUARD)) != 0) {
|
||||
return false;
|
||||
}
|
||||
at = region.end;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MatchesAt(u64 base, u64 offset, const u8* bytes, size_t size) {
|
||||
const u64 address = base + offset;
|
||||
return Readable(address, size) &&
|
||||
std::memcmp(reinterpret_cast<const void*>(address), bytes, size) == 0;
|
||||
}
|
||||
|
||||
bool Matches(u64 offset, const u8* bytes, size_t size) {
|
||||
const u64 address = EbootBase + offset;
|
||||
return Readable(address, size) &&
|
||||
std::memcmp(reinterpret_cast<const void*>(address), bytes, size) == 0;
|
||||
}
|
||||
|
||||
void Write(u64 offset, const u8* bytes, size_t size) {
|
||||
void* address = reinterpret_cast<void*>(EbootBase + offset);
|
||||
DWORD old = 0;
|
||||
if (!VirtualProtect(address, size, PAGE_EXECUTE_READWRITE, &old)) {
|
||||
return;
|
||||
}
|
||||
std::memcpy(address, bytes, size);
|
||||
DWORD ignored = 0;
|
||||
VirtualProtect(address, size, old, &ignored);
|
||||
FlushInstructionCache(GetCurrentProcess(), address, size);
|
||||
}
|
||||
|
||||
struct Site {
|
||||
const char* name;
|
||||
u64 offset;
|
||||
std::array<u8, 6> original{};
|
||||
std::array<u8, 6> patched{};
|
||||
size_t size;
|
||||
Group group;
|
||||
};
|
||||
|
||||
template <size_t N>
|
||||
std::array<u8, 6> Pad(const std::array<u8, N>& bytes) {
|
||||
std::array<u8, 6> out{};
|
||||
std::memcpy(out.data(), bytes.data(), N);
|
||||
return out;
|
||||
}
|
||||
|
||||
constexpr size_t SiteCount =
|
||||
Retargets.size() + EntryReturns.size() + ByteEdits.size() + WideEdits.size() + EntryJumps.size();
|
||||
std::array<Site, SiteCount> sites{};
|
||||
std::mutex patch_mutex;
|
||||
bool patches_ready = false;
|
||||
bool session_on = true; // session groups: off while a suspension runs
|
||||
bool role_on = false; // role groups (lamp): on while hosting
|
||||
|
||||
void BuildSites() {
|
||||
size_t i = 0;
|
||||
for (const auto& e : Retargets) {
|
||||
sites[i++] = {e.name, e.site, Pad(EncodeRel32(e.opcode, e.site, e.original)),
|
||||
Pad(EncodeRel32(e.opcode, e.site, e.target)), 5, e.group};
|
||||
}
|
||||
for (const auto& e : EntryReturns) {
|
||||
std::array<u8, 6> ret{};
|
||||
ret[0] = 0xC3;
|
||||
sites[i++] = {e.name, e.site, e.prologue, ret, 1, e.group};
|
||||
}
|
||||
for (const auto& e : ByteEdits) {
|
||||
sites[i++] = {e.name, e.site, Pad(e.original), Pad(e.replacement), 2, e.group};
|
||||
}
|
||||
for (const auto& e : WideEdits) {
|
||||
sites[i++] = {e.name, e.site, Pad(e.original), Pad(e.replacement), 4, e.group};
|
||||
}
|
||||
for (const auto& e : EntryJumps) {
|
||||
sites[i++] = {e.name, e.site, Pad(e.prologue), Pad(EncodeRel32(0xE9, e.site, e.target)), 5,
|
||||
e.group};
|
||||
}
|
||||
}
|
||||
|
||||
// The game image: where the fork puts it, else the start of any executable region whose first few
|
||||
// patch sites read as Bloodborne's (original or patched). AllSitesKnown then checks every site.
|
||||
u64 FindEbootBase() {
|
||||
const auto fits = [](u64 base) {
|
||||
int checked = 0;
|
||||
for (const Site& s : sites) {
|
||||
if (s.offset + s.size > EbootSize) {
|
||||
continue;
|
||||
}
|
||||
if (!MatchesAt(base, s.offset, s.original.data(), s.size) &&
|
||||
!MatchesAt(base, s.offset, s.patched.data(), s.size)) {
|
||||
return false;
|
||||
}
|
||||
if (++checked == 4) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return checked != 0;
|
||||
};
|
||||
if (fits(ForkEbootBase)) {
|
||||
return ForkEbootBase;
|
||||
}
|
||||
constexpr DWORD Executable = PAGE_EXECUTE | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE |
|
||||
PAGE_EXECUTE_WRITECOPY;
|
||||
MEMORY_BASIC_INFORMATION info{};
|
||||
u64 at = 0x10000;
|
||||
while (at < 0x7FFF'FFFF'0000ULL &&
|
||||
VirtualQuery(reinterpret_cast<void*>(at), &info, sizeof(info)) != 0) {
|
||||
const u64 base = reinterpret_cast<u64>(info.BaseAddress);
|
||||
if (info.State == MEM_COMMIT && (info.Protect & Executable) != 0 &&
|
||||
(info.Protect & PAGE_GUARD) == 0 && fits(base)) {
|
||||
return base;
|
||||
}
|
||||
at = base + info.RegionSize;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool AllSitesKnown(const Site** first_bad) {
|
||||
for (const Site& s : sites) {
|
||||
if (!GroupEnabled(s.group)) {
|
||||
continue;
|
||||
}
|
||||
if (s.offset + s.size > EbootSize ||
|
||||
(!Matches(s.offset, s.original.data(), s.size) &&
|
||||
!Matches(s.offset, s.patched.data(), s.size))) {
|
||||
*first_bad = &s;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Whether a site should be patched right now.
|
||||
bool Wanted(const Site& s) {
|
||||
if (!GroupEnabled(s.group)) {
|
||||
return false;
|
||||
}
|
||||
if (IsRoleGroup(s.group)) {
|
||||
return role_on;
|
||||
}
|
||||
if (IsSessionGroup(s.group)) {
|
||||
return session_on;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Brings every enabled site to its wanted form. Returns how many were written.
|
||||
int SyncSites() {
|
||||
int count = 0;
|
||||
for (const Site& s : sites) {
|
||||
if (!GroupEnabled(s.group)) {
|
||||
continue;
|
||||
}
|
||||
const auto& want = Wanted(s) ? s.patched : s.original;
|
||||
if (!Matches(s.offset, want.data(), s.size)) {
|
||||
Write(s.offset, want.data(), s.size);
|
||||
++count;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ---- Start-up ------------------------------------------------------------------------------------
|
||||
|
||||
// LICENSE.txt (the GPL) and NOTICE.txt (copyright + source link) ship in the mod folder so a copy
|
||||
// passed on keeps them. Missing or emptied -> BBSC stays off and says why.
|
||||
bool NoticesPresent() {
|
||||
auto holds = [](const wchar_t* name, const char* text) {
|
||||
FILE* file = _wfopen((mod_dir / name).c_str(), L"rb");
|
||||
if (file == nullptr) {
|
||||
return false;
|
||||
}
|
||||
std::string content(64 * 1024, '\0');
|
||||
content.resize(fread(content.data(), 1, content.size(), file));
|
||||
fclose(file);
|
||||
return content.find(text) != std::string::npos;
|
||||
};
|
||||
// SUPPORTERS.txt (2026-10-11, the user): the supporters list travels with BBSC like the
|
||||
// license; its names change freely, only the file and its header are required.
|
||||
return holds(L"LICENSE.txt", "GNU GENERAL PUBLIC LICENSE") &&
|
||||
holds(L"NOTICE.txt", "github.com/Classmg22/BBSC") &&
|
||||
holds(L"SUPPORTERS.txt", "BBSC SUPPORTERS");
|
||||
}
|
||||
|
||||
DWORD WINAPI Worker(void*) {
|
||||
OpenLog();
|
||||
wchar_t exe[MAX_PATH]{};
|
||||
GetModuleFileNameW(nullptr, exe, MAX_PATH);
|
||||
BbscMod::LogPlain("BBSC mod loaded - version " BBSC_VERSION);
|
||||
if (!NoticesPresent()) {
|
||||
LOG_ERROR(Mod, "LICENSE.txt / NOTICE.txt / SUPPORTERS.txt missing or changed in {} - BBSC "
|
||||
"is off",
|
||||
Common::FS::PathToUTF8String(mod_dir));
|
||||
MessageBoxW(nullptr,
|
||||
L"LICENSE.txt, NOTICE.txt or SUPPORTERS.txt is missing from the BBSC mod "
|
||||
L"folder (or was changed), so BBSC is off.\n\nThese files have to travel with "
|
||||
L"BBSC.\nDownload the full package again "
|
||||
L"from:\nhttps://github.com/Classmg22/BBSC",
|
||||
L"BBSC", MB_OK | MB_ICONERROR | MB_SETFOREGROUND);
|
||||
return 0;
|
||||
}
|
||||
LOG_INFO(Mod, "BBSC mod in pid {} ({})", GetCurrentProcessId(),
|
||||
Common::FS::PathToUTF8String(std::filesystem::path{exe}));
|
||||
// Public servers (2026-10-11, shadNet's author: The Hunter's Dream is exact to the official
|
||||
// server, custom summon behaviour does not work there). Wozzardman's seamless summon patches
|
||||
// (cross-map sign search, host-placement summon build) and the 5-hunter search patches change
|
||||
// what the game asks the summon server for, so they are off there whatever the switches say
|
||||
// (review 2026-10-11: the Mods tab writes its defaults, BBSC_MOD_SEAMLESS=1 among them, at
|
||||
// every start, so "a switch the player set wins" would have kept them on).
|
||||
{
|
||||
std::string server;
|
||||
if (BbscMod::GameServerIsPublic(&server)) {
|
||||
const auto force_off = [](const char* name) {
|
||||
_putenv_s(name, "0");
|
||||
SetEnvironmentVariableA(name, "0");
|
||||
};
|
||||
force_off("BBSC_MOD_SEAMLESS");
|
||||
force_off("SHADPS4_BBSC_FIVE_HUNTERS");
|
||||
LOG_INFO(Mod, "Public game server ({}): vanilla summons - seamless summon patches and the "
|
||||
"5-hunter search off (the session itself stays seamless)",
|
||||
server);
|
||||
}
|
||||
}
|
||||
BuildSites();
|
||||
|
||||
const DWORD started = GetTickCount();
|
||||
while ((EbootBase = FindEbootBase()) == 0) {
|
||||
if (GetTickCount() - started > WaitForImageMs) {
|
||||
LOG_ERROR(Mod, "No Bloodborne CUSA03173 01.09 image in memory after {} s - not this "
|
||||
"game or not this version; nothing was changed",
|
||||
WaitForImageMs / 1000);
|
||||
return 0;
|
||||
}
|
||||
Sleep(50);
|
||||
}
|
||||
LOG_INFO(Mod, "Game image found at {:#x}{}", EbootBase,
|
||||
EbootBase == ForkEbootBase ? "" : " (not the shadp2p fork's address)");
|
||||
// shadPS4 maps the game's code writable; other hosts (bbhost) keep it read-only, and the
|
||||
// shared hook code writes some code bytes directly. Make the image's code writable like shadPS4.
|
||||
{
|
||||
int unlocked = 0;
|
||||
MEMORY_BASIC_INFORMATION info{};
|
||||
u64 at = EbootBase;
|
||||
while (at < EbootBase + EbootSize &&
|
||||
VirtualQuery(reinterpret_cast<void*>(at), &info, sizeof(info)) != 0) {
|
||||
const u64 next = reinterpret_cast<u64>(info.BaseAddress) + info.RegionSize;
|
||||
if (info.State == MEM_COMMIT &&
|
||||
(info.Protect == PAGE_EXECUTE_READ || info.Protect == PAGE_EXECUTE)) {
|
||||
DWORD old = 0;
|
||||
const u64 end = std::min<u64>(next, EbootBase + EbootSize);
|
||||
if (VirtualProtect(info.BaseAddress, end - reinterpret_cast<u64>(info.BaseAddress),
|
||||
PAGE_EXECUTE_READWRITE, &old)) {
|
||||
++unlocked;
|
||||
}
|
||||
}
|
||||
at = next;
|
||||
}
|
||||
if (unlocked != 0) {
|
||||
LOG_INFO(Mod, "Game code made writable ({} region(s)) - this host maps it read-only",
|
||||
unlocked);
|
||||
}
|
||||
}
|
||||
const Site* bad = nullptr;
|
||||
while (!AllSitesKnown(&bad)) {
|
||||
if (GetTickCount() - started > WaitForImageMs) {
|
||||
LOG_ERROR(Mod,
|
||||
"No Bloodborne CUSA03173 01.09 image after {} s (first unknown site: {} at "
|
||||
"{:#x}) - not this game or not this version; nothing was changed",
|
||||
WaitForImageMs / 1000, bad ? bad->name : "?", bad ? bad->offset : 0);
|
||||
return 0;
|
||||
}
|
||||
Sleep(1);
|
||||
}
|
||||
Sleep(SettleMs);
|
||||
const DWORD verified = GetTickCount() - started;
|
||||
|
||||
MemoryPatcher::g_eboot_address = EbootBase;
|
||||
MemoryPatcher::g_eboot_image_size = EbootSize;
|
||||
MemoryPatcher::g_game_serial = "CUSA03173";
|
||||
|
||||
// Wozzardman's seamless patches first: one of them (0x019471B1) is also in BBSC's list, and it
|
||||
// checks for the original bytes.
|
||||
Core::Bloodborne::InstallSeamlessCoopPatches();
|
||||
|
||||
int written = 0;
|
||||
{
|
||||
std::scoped_lock lock{patch_mutex};
|
||||
patches_ready = true;
|
||||
written = SyncSites();
|
||||
}
|
||||
LOG_INFO(Mod, "Image verified after {} ms: {} byte patch sites written", verified, written);
|
||||
|
||||
Core::Bloodborne::InstallReverseEngineeringTrace();
|
||||
BbscMod::StartRelay();
|
||||
BbscMod::StartP2PChannel();
|
||||
BbscMod::InstallHttpWrappers();
|
||||
Core::Bbsc::InstallPadWrapper();
|
||||
BbscMod::StartDiscordStatus();
|
||||
LOG_INFO(Mod, "BBSC mod ready after {} ms", GetTickCount() - started);
|
||||
|
||||
for (;;) {
|
||||
Sleep(WatchMs);
|
||||
std::scoped_lock lock{patch_mutex};
|
||||
if (const int redone = SyncSites(); redone != 0) {
|
||||
LOG_WARNING(Mod, "{} patch site(s) had changed back - written again", redone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ---- Logging -------------------------------------------------------------------------------------
|
||||
|
||||
namespace BbscMod {
|
||||
|
||||
void LogLine(char level, const char* category, const std::string& text) {
|
||||
std::scoped_lock lock{log_mutex};
|
||||
if (log_file == nullptr) {
|
||||
return;
|
||||
}
|
||||
SYSTEMTIME now;
|
||||
GetLocalTime(&now);
|
||||
std::fprintf(log_file, "[%02u:%02u:%02u.%03u] %c %s: %s\n", now.wHour, now.wMinute, now.wSecond,
|
||||
now.wMilliseconds, level, category, text.c_str());
|
||||
std::fflush(log_file);
|
||||
}
|
||||
|
||||
void LogPlain(const char* text) {
|
||||
LogLine('I', "Mod", text);
|
||||
}
|
||||
|
||||
u64 EbootAddress() {
|
||||
return EbootBase;
|
||||
}
|
||||
|
||||
// Import slots the DLL swapped for its wrappers (bbsc_http.cpp, bbsc_channel.cpp). The official
|
||||
// shadPS4 links the eboot's imports again when the game loads a library later (2026-10-10: no
|
||||
// wrapper ever ran on the nightly - the slots were written back to the emulator's functions), so a
|
||||
// thread puts the wrapper back whenever a slot no longer points at it, taking the new value as the
|
||||
// original.
|
||||
namespace {
|
||||
struct GuardedSlot {
|
||||
u64 slot_offset;
|
||||
u64 wrapper;
|
||||
void** original; // the wrapper's function pointer (an aligned 8-byte store)
|
||||
const char* name;
|
||||
int rewritten = 0;
|
||||
};
|
||||
std::mutex guarded_mutex;
|
||||
std::vector<GuardedSlot> guarded_slots;
|
||||
|
||||
DWORD WINAPI SlotGuardThread(void*) {
|
||||
for (;;) {
|
||||
Sleep(5);
|
||||
std::scoped_lock lock{guarded_mutex};
|
||||
for (auto& guarded : guarded_slots) {
|
||||
auto* slot = reinterpret_cast<volatile u64*>(EbootBase + guarded.slot_offset);
|
||||
const u64 value = *slot;
|
||||
if (value == guarded.wrapper || value == 0 ||
|
||||
(value >= EbootBase && value < EbootBase + EbootSize)) {
|
||||
continue;
|
||||
}
|
||||
*reinterpret_cast<void* volatile*>(guarded.original) = reinterpret_cast<void*>(value);
|
||||
DWORD old = 0;
|
||||
VirtualProtect(const_cast<u64*>(slot), 8, PAGE_READWRITE, &old);
|
||||
*slot = guarded.wrapper;
|
||||
DWORD ignored = 0;
|
||||
VirtualProtect(const_cast<u64*>(slot), 8, old, &ignored);
|
||||
if (++guarded.rewritten <= 3) {
|
||||
LOG_INFO(Mod, "Import {} was linked again by the emulator - wrapper put back ({}x)",
|
||||
guarded.name, guarded.rewritten);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void GuardSlot(u64 slot_offset, u64 wrapper, void** original, const char* name) {
|
||||
std::scoped_lock lock{guarded_mutex};
|
||||
if (guarded_slots.empty()) {
|
||||
if (HANDLE thread = CreateThread(nullptr, 0, SlotGuardThread, nullptr, 0, nullptr)) {
|
||||
CloseHandle(thread);
|
||||
}
|
||||
}
|
||||
guarded_slots.push_back({slot_offset, wrapper, original, name});
|
||||
}
|
||||
|
||||
} // namespace BbscMod
|
||||
|
||||
// ---- What bloodborne_re.cpp asks the emulator for ----------------------------------------------
|
||||
|
||||
namespace Core {
|
||||
|
||||
bool MemoryManager::IsValidMapping(u64 address, u64 size) {
|
||||
return Readable(address, size == 0 ? 1 : size);
|
||||
}
|
||||
|
||||
s32 MemoryManager::QueryProtection(u64 address, void** start, void** end, u32* prot) {
|
||||
CachedRegion info;
|
||||
if (!QueryRegion(address, info) || info.state != MEM_COMMIT) {
|
||||
return -1;
|
||||
}
|
||||
*start = reinterpret_cast<void*>(info.start);
|
||||
*end = reinterpret_cast<void*>(info.end);
|
||||
u32 bits = 0;
|
||||
switch (info.protect & 0xFF) {
|
||||
case PAGE_READONLY:
|
||||
bits = 1;
|
||||
break;
|
||||
case PAGE_READWRITE:
|
||||
case PAGE_WRITECOPY:
|
||||
bits = 3;
|
||||
break;
|
||||
case PAGE_EXECUTE_READ:
|
||||
bits = 5;
|
||||
break;
|
||||
case PAGE_EXECUTE_READWRITE:
|
||||
case PAGE_EXECUTE_WRITECOPY:
|
||||
bits = 7;
|
||||
break;
|
||||
case PAGE_EXECUTE:
|
||||
bits = 4;
|
||||
break;
|
||||
default:
|
||||
bits = 0;
|
||||
}
|
||||
if ((info.protect & PAGE_GUARD) != 0) {
|
||||
bits = 0;
|
||||
}
|
||||
*prot = bits;
|
||||
return 0;
|
||||
}
|
||||
|
||||
namespace Debugger {
|
||||
int GetCurrentPid() {
|
||||
return static_cast<int>(GetCurrentProcessId());
|
||||
}
|
||||
} // namespace Debugger
|
||||
|
||||
namespace Bbsc {
|
||||
|
||||
void InstallPersistentSessionPatches() {} // the DLL applies them itself (Worker)
|
||||
|
||||
void SetPersistentSessionPatches(bool enabled) {
|
||||
std::scoped_lock lock{patch_mutex};
|
||||
if (!patches_ready || session_on == enabled) {
|
||||
return;
|
||||
}
|
||||
session_on = enabled;
|
||||
const int count = SyncSites();
|
||||
LOG_INFO(Debug, "BBSC persistence {} ({} session sites; echoes, lamp, fog walls and deaths "
|
||||
"untouched)",
|
||||
enabled ? "re-applied" : "suspended", count);
|
||||
}
|
||||
|
||||
void SetHostLampPatches(bool hosting) {
|
||||
std::scoped_lock lock{patch_mutex};
|
||||
if (!patches_ready || role_on == hosting) {
|
||||
return;
|
||||
}
|
||||
role_on = hosting;
|
||||
const int count = SyncSites();
|
||||
LOG_INFO(Debug, "BBSC lamp: {} ({} sites)",
|
||||
hosting ? "prompt allowed (hosting a session)"
|
||||
: "vanilla gates (solo or guest - a guest never gets the lamp)",
|
||||
count);
|
||||
}
|
||||
|
||||
// The F1 camera. The emulator build feeds it from its SDL window events (bbsc_cinematic.cpp); a mod
|
||||
// sees no window events, so the keys are polled here instead, only while this process's window is
|
||||
// in front (Nucleus runs two). While it is on, CineWindowProc keeps the keys and the mouse from the
|
||||
// game (2026-10-12; before, they reached it too). bloodborne_re.cpp turns it on by itself when you
|
||||
// die in co-op and off when you respawn.
|
||||
// 2026-10-05 (the user: better mouse controls): the mouse looks around without a button held - the
|
||||
// cursor is hidden and kept in the middle of the game window - and the wheel sets the speed (the
|
||||
// game window is subclassed for WM_MOUSEWHEEL / WM_SETCURSOR while the camera is on; PageUp /
|
||||
// PageDown still work).
|
||||
std::mutex cine_mutex;
|
||||
CinematicInput cine_state;
|
||||
std::array<bool, 256> cine_was_down{};
|
||||
bool cine_looking = false;
|
||||
POINT cine_anchor{};
|
||||
HWND cine_window = nullptr; // the subclassed game window (SDL's "SDL_app")
|
||||
WNDPROC cine_window_proc = nullptr; // its own window procedure
|
||||
std::atomic<bool> cine_mouse_owned{false}; // the camera is on and has the mouse
|
||||
std::atomic<int> cine_wheel{0}; // wheel notches since the last TakeCinematicInput
|
||||
|
||||
LRESULT CALLBACK CineWindowProc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) {
|
||||
if (cine_mouse_owned) {
|
||||
if (message == WM_MOUSEWHEEL) {
|
||||
const int delta = GET_WHEEL_DELTA_WPARAM(wparam);
|
||||
cine_wheel += delta > 0 ? 1 : (delta < 0 ? -1 : 0);
|
||||
return 0; // not for the game
|
||||
}
|
||||
if (message == WM_SETCURSOR && LOWORD(lparam) == HTCLIENT) {
|
||||
SetCursor(nullptr);
|
||||
return TRUE;
|
||||
}
|
||||
// 2026-10-12 (the user: "each button does a million things"): the camera's keys walked,
|
||||
// dodged and used items at the same time, and the mouse turned the game's camera. While
|
||||
// the camera is on, presses and movement stop here; the emulator (SDL) never sees them.
|
||||
// Releases still go through, so a key held when the camera came on is not left stuck.
|
||||
// The camera reads the keys itself (GetAsyncKeyState), so it is not affected. Alt+F4 passes.
|
||||
switch (message) {
|
||||
case WM_KEYDOWN:
|
||||
case WM_CHAR:
|
||||
case WM_DEADCHAR:
|
||||
case WM_SYSCHAR:
|
||||
case WM_LBUTTONDOWN:
|
||||
case WM_LBUTTONDBLCLK:
|
||||
case WM_RBUTTONDOWN:
|
||||
case WM_RBUTTONDBLCLK:
|
||||
case WM_MBUTTONDOWN:
|
||||
case WM_MBUTTONDBLCLK:
|
||||
case WM_XBUTTONDOWN:
|
||||
case WM_XBUTTONDBLCLK:
|
||||
case WM_MOUSEMOVE:
|
||||
return 0;
|
||||
case WM_SYSKEYDOWN:
|
||||
if (wparam != VK_F4) {
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_INPUT: {
|
||||
// Raw input (SDL's relative mouse): drop movement and presses, keep releases.
|
||||
RAWINPUT raw{};
|
||||
UINT size = sizeof(raw);
|
||||
if (GetRawInputData(reinterpret_cast<HRAWINPUT>(lparam), RID_INPUT, &raw, &size,
|
||||
sizeof(RAWINPUTHEADER)) != static_cast<UINT>(-1)) {
|
||||
bool release = false;
|
||||
if (raw.header.dwType == RIM_TYPEKEYBOARD) {
|
||||
release = (raw.data.keyboard.Flags & RI_KEY_BREAK) != 0;
|
||||
} else if (raw.header.dwType == RIM_TYPEMOUSE) {
|
||||
constexpr USHORT Ups = RI_MOUSE_BUTTON_1_UP | RI_MOUSE_BUTTON_2_UP |
|
||||
RI_MOUSE_BUTTON_3_UP | RI_MOUSE_BUTTON_4_UP |
|
||||
RI_MOUSE_BUTTON_5_UP;
|
||||
release = (raw.data.mouse.usButtonFlags & Ups) != 0;
|
||||
}
|
||||
if (!release) {
|
||||
return DefWindowProcW(window, message, wparam, lparam);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return CallWindowProcW(cine_window_proc, window, message, wparam, lparam);
|
||||
}
|
||||
|
||||
// The game window when it is in front, else nullptr. Subclassed the first time it is seen.
|
||||
HWND CineGameWindowInFront() {
|
||||
HWND window = GetForegroundWindow();
|
||||
DWORD pid = 0;
|
||||
GetWindowThreadProcessId(window, &pid);
|
||||
if (window == nullptr || pid != GetCurrentProcessId()) {
|
||||
return nullptr;
|
||||
}
|
||||
char cls[32]{};
|
||||
GetClassNameA(window, cls, sizeof(cls));
|
||||
if (std::strcmp(cls, "SDL_app") != 0) {
|
||||
return nullptr; // e.g. the console
|
||||
}
|
||||
if (cine_window != window) {
|
||||
if (cine_window != nullptr && cine_window_proc != nullptr) {
|
||||
SetWindowLongPtrW(cine_window, GWLP_WNDPROC,
|
||||
reinterpret_cast<LONG_PTR>(cine_window_proc));
|
||||
}
|
||||
cine_window = window;
|
||||
cine_window_proc = reinterpret_cast<WNDPROC>(SetWindowLongPtrW(
|
||||
window, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(&CineWindowProc)));
|
||||
LOG_INFO(Debug, "BBSC cinematic camera: mouse wheel and cursor hooked on the game window");
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
// The controller: the game's scePadReadState import slot (0x053E4EA0, tools/gamedata/
|
||||
// eboot_imports.py) is swapped for PadReadState below, which sees the pad data the emulator hands
|
||||
// the game - any controller the emulator supports. L3 + R3 together toggles the camera; while it is
|
||||
// on the sticks and buttons fly it and the game gets a pad at rest (the hunter does not move).
|
||||
constexpr u64 PadReadStateSlot = 0x053E4EA0;
|
||||
using PadReadStateFn = int PS4_SYSV_ABI (*)(s32 handle, u8* data);
|
||||
PadReadStateFn original_pad_read = nullptr;
|
||||
constexpr u32 PadL3 = 0x2, PadR3 = 0x4, PadUp = 0x10, PadRight = 0x20, PadDown = 0x40,
|
||||
PadLeft = 0x80, PadL1 = 0x400, PadR1 = 0x800, PadTriangle = 0x1000,
|
||||
PadCross = 0x4000, PadSquare = 0x8000;
|
||||
struct CinePad {
|
||||
u32 buttons = 0;
|
||||
u32 last_buttons = 0;
|
||||
float lx = 0.0f, ly = 0.0f, rx = 0.0f, ry = 0.0f, l2 = 0.0f, r2 = 0.0f;
|
||||
int speed_steps = 0, smooth_steps = 0;
|
||||
bool zoom_reset = false;
|
||||
};
|
||||
CinePad cine_pad;
|
||||
|
||||
float PadAxis(u8 value) {
|
||||
const float v = (static_cast<float>(value) - 128.0f) / 127.0f;
|
||||
constexpr float Dead = 0.15f;
|
||||
return std::abs(v) < Dead ? 0.0f : std::clamp((v - std::copysign(Dead, v)) / (1.0f - Dead), -1.0f, 1.0f);
|
||||
}
|
||||
|
||||
// OrbisPadData: +0 buttons (u32), +4 left stick x/y, +6 right stick x/y, +8 L2, +9 R2 (u8).
|
||||
int PS4_SYSV_ABI PadReadState(s32 handle, u8* data) {
|
||||
const int result = original_pad_read(handle, data);
|
||||
if (result != 0 || data == nullptr) {
|
||||
return result;
|
||||
}
|
||||
u32 buttons = 0;
|
||||
std::memcpy(&buttons, data, 4);
|
||||
std::scoped_lock lock{cine_mutex};
|
||||
const u32 pressed = buttons & ~cine_pad.last_buttons;
|
||||
cine_pad.last_buttons = buttons;
|
||||
if ((buttons & (PadL3 | PadR3)) == (PadL3 | PadR3) && (pressed & (PadL3 | PadR3)) != 0) {
|
||||
const bool enabled = !cine_state.enabled;
|
||||
cine_state = CinematicInput{};
|
||||
cine_state.enabled = enabled;
|
||||
LOG_INFO(Debug, "BBSC cinematic camera: {} (L3 + R3)", enabled ? "on" : "off");
|
||||
}
|
||||
if (!cine_state.enabled) {
|
||||
cine_pad = CinePad{.last_buttons = buttons};
|
||||
return result;
|
||||
}
|
||||
cine_pad.buttons = buttons;
|
||||
cine_pad.lx = PadAxis(data[4]);
|
||||
cine_pad.ly = PadAxis(data[5]);
|
||||
cine_pad.rx = PadAxis(data[6]);
|
||||
cine_pad.ry = PadAxis(data[7]);
|
||||
cine_pad.l2 = static_cast<float>(data[8]) / 255.0f;
|
||||
cine_pad.r2 = static_cast<float>(data[9]) / 255.0f;
|
||||
cine_pad.speed_steps += ((pressed & PadUp) ? 1 : 0) - ((pressed & PadDown) ? 1 : 0);
|
||||
cine_pad.smooth_steps += ((pressed & PadRight) ? 1 : 0) - ((pressed & PadLeft) ? 1 : 0);
|
||||
cine_pad.zoom_reset = cine_pad.zoom_reset || (pressed & PadTriangle) != 0;
|
||||
// The game sees a controller at rest (connected, nothing pressed).
|
||||
const u32 rest_buttons = buttons & 0x80000000u;
|
||||
std::memcpy(data, &rest_buttons, 4);
|
||||
data[4] = data[5] = data[6] = data[7] = 128;
|
||||
data[8] = data[9] = 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
void InstallPadWrapper() {
|
||||
auto* slot = reinterpret_cast<u64*>(EbootBase + PadReadStateSlot);
|
||||
for (int i = 0; i < 60'000; ++i) {
|
||||
const u64 value = *slot;
|
||||
if (value != 0 && (value < EbootBase || value >= EbootBase + EbootSize)) {
|
||||
original_pad_read = reinterpret_cast<PadReadStateFn>(value);
|
||||
DWORD old = 0;
|
||||
VirtualProtect(slot, 8, PAGE_READWRITE, &old);
|
||||
*slot = reinterpret_cast<u64>(&PadReadState);
|
||||
DWORD ignored = 0;
|
||||
VirtualProtect(slot, 8, old, &ignored);
|
||||
LOG_INFO(Mod, "Pad: controller works the F1 camera too (L3 + R3)");
|
||||
return;
|
||||
}
|
||||
Sleep(1);
|
||||
}
|
||||
LOG_WARNING(Mod, "Pad: scePadReadState slot never filled - F1 camera keyboard only");
|
||||
}
|
||||
|
||||
bool CineKeyPressed(int key, bool down) {
|
||||
const bool pressed = down && !cine_was_down[key];
|
||||
cine_was_down[key] = down;
|
||||
return pressed;
|
||||
}
|
||||
|
||||
CinematicInput TakeCinematicInput() {
|
||||
std::scoped_lock lock{cine_mutex};
|
||||
DWORD pid = 0;
|
||||
GetWindowThreadProcessId(GetForegroundWindow(), &pid);
|
||||
const bool front = pid == GetCurrentProcessId();
|
||||
auto down = [front](int key) { return front && (GetAsyncKeyState(key) & 0x8000) != 0; };
|
||||
if (CineKeyPressed(VK_F1, down(VK_F1))) {
|
||||
const bool enabled = !cine_state.enabled;
|
||||
cine_state = CinematicInput{};
|
||||
cine_state.enabled = enabled;
|
||||
LOG_INFO(Debug, "BBSC cinematic camera: {} (F1)", enabled ? "on" : "off");
|
||||
}
|
||||
if (!cine_state.enabled) {
|
||||
cine_looking = false;
|
||||
cine_mouse_owned = false;
|
||||
cine_wheel = 0;
|
||||
return {};
|
||||
}
|
||||
CinematicInput& c = cine_state;
|
||||
const CinePad& p = cine_pad;
|
||||
c.forward = down('W');
|
||||
c.back = down('S');
|
||||
c.left = down('A');
|
||||
c.right = down('D');
|
||||
c.up = down('E') || down(VK_SPACE);
|
||||
c.down = down('Q') || down('C');
|
||||
c.fast = down(VK_SHIFT) || (p.buttons & PadCross) != 0;
|
||||
c.slow = down(VK_MENU) || (p.buttons & PadSquare) != 0;
|
||||
c.zoom_in = down('Z');
|
||||
c.zoom_out = down('X');
|
||||
c.zoom_reset = CineKeyPressed('V', down('V')) || p.zoom_reset;
|
||||
cine_pad.zoom_reset = false;
|
||||
c.roll_left = down(VK_OEM_COMMA);
|
||||
c.roll_right = down(VK_OEM_PERIOD);
|
||||
c.roll_reset = CineKeyPressed(VK_OEM_2, down(VK_OEM_2));
|
||||
c.look_up = down(VK_UP);
|
||||
c.look_down = down(VK_DOWN);
|
||||
c.look_left = down(VK_LEFT);
|
||||
c.look_right = down(VK_RIGHT);
|
||||
c.wheel = (CineKeyPressed(VK_PRIOR, down(VK_PRIOR)) ? 1 : 0) -
|
||||
(CineKeyPressed(VK_NEXT, down(VK_NEXT)) ? 1 : 0) + p.speed_steps +
|
||||
cine_wheel.exchange(0);
|
||||
c.smooth_steps = (CineKeyPressed(VK_OEM_6, down(VK_OEM_6)) ? 1 : 0) -
|
||||
(CineKeyPressed(VK_OEM_4, down(VK_OEM_4)) ? 1 : 0) + p.smooth_steps;
|
||||
cine_pad.speed_steps = 0;
|
||||
cine_pad.smooth_steps = 0;
|
||||
c.pad_move_x = p.lx;
|
||||
c.pad_move_y = -p.ly; // stick up = smaller y = forward
|
||||
c.pad_rise = ((p.buttons & PadR1) ? 1.0f : 0.0f) - ((p.buttons & PadL1) ? 1.0f : 0.0f);
|
||||
c.pad_look_x = p.rx;
|
||||
c.pad_look_y = -p.ry;
|
||||
c.pad_zoom = p.r2 - p.l2;
|
||||
// Mouse look, no button needed: the cursor's movement, put back in the middle of the game
|
||||
// window each frame (hidden there by CineWindowProc).
|
||||
c.mouse_dx = 0.0f;
|
||||
c.mouse_dy = 0.0f;
|
||||
POINT now{};
|
||||
RECT client{};
|
||||
HWND window = front ? CineGameWindowInFront() : nullptr;
|
||||
if (window != nullptr && GetClientRect(window, &client) && GetCursorPos(&now)) {
|
||||
POINT center{(client.left + client.right) / 2, (client.top + client.bottom) / 2};
|
||||
ClientToScreen(window, ¢er);
|
||||
if (cine_looking) {
|
||||
c.mouse_dx = static_cast<float>(now.x - cine_anchor.x);
|
||||
c.mouse_dy = static_cast<float>(now.y - cine_anchor.y);
|
||||
}
|
||||
cine_anchor = center;
|
||||
cine_looking = true;
|
||||
SetCursorPos(center.x, center.y);
|
||||
cine_mouse_owned = true;
|
||||
} else {
|
||||
cine_looking = false; // another window is in front: the mouse is the user's
|
||||
cine_mouse_owned = false;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
void CinematicForceOff() {
|
||||
std::scoped_lock lock{cine_mutex};
|
||||
cine_state = CinematicInput{};
|
||||
cine_pad = CinePad{.last_buttons = cine_pad.last_buttons};
|
||||
cine_looking = false;
|
||||
cine_mouse_owned = false;
|
||||
}
|
||||
|
||||
void CinematicForceOn() {
|
||||
std::scoped_lock lock{cine_mutex};
|
||||
if (cine_state.enabled) {
|
||||
return;
|
||||
}
|
||||
cine_state = CinematicInput{};
|
||||
cine_state.enabled = true;
|
||||
cine_pad = CinePad{.last_buttons = cine_pad.last_buttons};
|
||||
cine_looking = false;
|
||||
}
|
||||
|
||||
} // namespace Bbsc
|
||||
} // namespace Core
|
||||
|
||||
namespace Common::FS {
|
||||
std::filesystem::path GetUserPath(PathType type) {
|
||||
return type == PathType::CapturesDir ? mod_dir / "captures" : mod_dir;
|
||||
}
|
||||
} // namespace Common::FS
|
||||
|
||||
namespace Libraries::Np::NpMatching2 {
|
||||
int BbscReplayRoomMembers(bool) {
|
||||
return 0; // the emulator's room cache is out of reach for a mod
|
||||
}
|
||||
} // namespace Libraries::Np::NpMatching2
|
||||
|
||||
BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID) {
|
||||
if (reason == DLL_PROCESS_ATTACH) {
|
||||
self_module = module;
|
||||
DisableThreadLibraryCalls(module);
|
||||
if (HANDLE thread = CreateThread(nullptr, 0, Worker, nullptr, 0, nullptr)) {
|
||||
CloseHandle(thread);
|
||||
}
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// BBSC mod: the new home of BBSC's own packets (host position / follow, world flags, Dream state).
|
||||
// In the emulator build they rode on the emulator's P2P signaling. A mod cannot reach that, so
|
||||
// they go through the BBSC game server instead, which every host already runs and every player's
|
||||
// game already talks to: one TCP connection to its relay port (20444), frames of
|
||||
// [u32 little-endian length][payload], and the server hands every frame to every other player
|
||||
// connected to it. The payloads are the signaling packets unchanged ("SHAD" + type 0x20 follow,
|
||||
// 0x22 world flags), so the receiving code is the same.
|
||||
//
|
||||
// No relay (public servers, or ours not reachable): the same frames go over the game's own P2P
|
||||
// connection instead (bbsc_channel.cpp), and come back into Dispatch from there.
|
||||
//
|
||||
// Server address: BBSC_RELAY=host[:port], else the game server entry of host_overrides.json
|
||||
// (portable install: <emulator>\user\, else %APPDATA%\shadPS4\) - the address JOIN-HOST wrote.
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <winsock2.h>
|
||||
#include <ws2tcpip.h>
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include "linux/bbsc_win32.h" // bbport
|
||||
#endif
|
||||
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <mutex>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "common/types.h"
|
||||
#include "core/bloodborne_re.h"
|
||||
#include "core/libraries/np/np_signaling/np_signaling_bbsc.h"
|
||||
#include "core/libraries/np/np_signaling/np_signaling_helpers.h"
|
||||
|
||||
namespace BbscMod {
|
||||
void LogPlain(const char* text);
|
||||
void OnHostPlacement(const std::string& placement, u32 sender);
|
||||
bool IsPublicServerAddress(const std::string& url_or_host); // bbsc_http.cpp
|
||||
u32 P2PChannelSend(const void* data, u32 size); // bbsc_channel.cpp
|
||||
u32 P2PChannelPeerCount();
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr u16 DefaultRelayPort = 20444;
|
||||
constexpr u8 FollowType = 0x20;
|
||||
constexpr u8 PlacementType = 0x30; // "SHAD" 0x30 + a host's placement text (bbsc_http.cpp)
|
||||
constexpr u8 LifeType = 0x23; // "SHAD" 0x23 + u32 player, u8 dead, u32 sequence
|
||||
constexpr u8 ScaleType = 0x24; // "SHAD" 0x24 + 5 x u16 the host's scaling percentages
|
||||
constexpr u8 PvpType = 0x25; // "SHAD" 0x25 + u8 kind (0 host state, 1 guest asks) + u8 on + u32
|
||||
constexpr u8 ProgressType = 0x26; // "SHAD" 0x26 + u8 count + count x u32 the host's boss flags
|
||||
constexpr u8 HostBossType = 0x27; // "SHAD" 0x27 + s32 the boss on the host's boss bar (-1 none)
|
||||
constexpr u8 PeersType = 0x7F; // server -> client: "N other players are connected", 1 byte count
|
||||
// Every frame since 2026-10-06: "SHAD" 0x40 + u32 sender + u32 session (the host's sender id, 0 = in
|
||||
// no session) + the packet above. The server sends each frame to everyone connected to it, so the
|
||||
// receiver keeps only its own session's packets (see "Relay sessions" in bloodborne_re.cpp).
|
||||
// Frames without it come from an older BBSC and are dropped.
|
||||
constexpr u8 EnvelopeType = 0x40;
|
||||
constexpr size_t EnvelopeSize = 13;
|
||||
|
||||
#pragma pack(push, 1)
|
||||
struct FollowPacket {
|
||||
u8 magic[4] = {'S', 'H', 'A', 'D'};
|
||||
u8 type = FollowType;
|
||||
u8 kind = 0;
|
||||
u32 packed_map = 0;
|
||||
float x = 0.0f;
|
||||
float y = 0.0f;
|
||||
float z = 0.0f;
|
||||
float heading = 0.0f;
|
||||
u32 sequence = 0;
|
||||
s32 lamp_point = -1;
|
||||
};
|
||||
#pragma pack(pop)
|
||||
static_assert(sizeof(FollowPacket) == 0x22);
|
||||
|
||||
std::mutex send_mutex;
|
||||
std::mutex dispatch_mutex; // the relay thread and the P2P channel's thread both deliver frames
|
||||
SOCKET relay = INVALID_SOCKET;
|
||||
std::atomic<int> peers{0};
|
||||
std::atomic<bool> started{false};
|
||||
u32 own_id = 0; // set once in StartRelay, never 0
|
||||
std::atomic<bool> logged_old_sender{false};
|
||||
|
||||
void Log(const std::string& text) {
|
||||
BbscMod::LogPlain(("Relay: " + text).c_str());
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
std::string ReadFile(const std::wstring& path) {
|
||||
#else
|
||||
std::string ReadFile(const std::string& path) {
|
||||
#endif
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
std::stringstream text;
|
||||
text << in.rdbuf();
|
||||
return text.str();
|
||||
}
|
||||
|
||||
// "http://26.1.2.3:20443" as the value of the ss4.scej-network.jp key -> "26.1.2.3".
|
||||
std::string HostFromOverrides(const std::string& json) {
|
||||
const size_t key = json.find("ss4.scej-network.jp");
|
||||
if (key == std::string::npos) {
|
||||
return {};
|
||||
}
|
||||
const size_t colon = json.find(':', json.find('"', key + 1) + 1);
|
||||
const size_t open = json.find('"', colon);
|
||||
const size_t close = json.find('"', open + 1);
|
||||
if (colon == std::string::npos || open == std::string::npos || close == std::string::npos) {
|
||||
return {};
|
||||
}
|
||||
std::string url = json.substr(open + 1, close - open - 1);
|
||||
if (const size_t scheme = url.find("://"); scheme != std::string::npos) {
|
||||
url = url.substr(scheme + 3);
|
||||
}
|
||||
return url.substr(0, url.find_first_of(":/"));
|
||||
}
|
||||
|
||||
bool FindServer(std::string& host, u16& port) {
|
||||
port = DefaultRelayPort;
|
||||
if (const char* env = std::getenv("BBSC_RELAY"); env != nullptr && env[0] != '\0') {
|
||||
std::string value = env;
|
||||
if (const size_t colon = value.find(':'); colon != std::string::npos) {
|
||||
port = static_cast<u16>(std::atoi(value.c_str() + colon + 1));
|
||||
value.resize(colon);
|
||||
}
|
||||
host = value;
|
||||
return true;
|
||||
}
|
||||
#ifdef _WIN32
|
||||
wchar_t exe[MAX_PATH]{};
|
||||
GetModuleFileNameW(nullptr, exe, MAX_PATH);
|
||||
std::wstring dir{exe};
|
||||
dir = dir.substr(0, dir.find_last_of(L"\\/") + 1);
|
||||
// The emulator's own rule: a "user" folder in the folder it was started in wins (portable).
|
||||
wchar_t cwd[MAX_PATH]{};
|
||||
GetCurrentDirectoryW(MAX_PATH, cwd);
|
||||
std::vector<std::wstring> candidates{std::wstring(cwd) + L"\\user\\host_overrides.json",
|
||||
dir + L"user\\host_overrides.json"};
|
||||
if (const wchar_t* appdata = _wgetenv(L"APPDATA")) {
|
||||
candidates.push_back(std::wstring(appdata) + L"\\shadPS4\\host_overrides.json");
|
||||
}
|
||||
#else
|
||||
// bbport: the overrides file run.sh hands the online module (SHADPS4_HTTP_HOST_OVERRIDES_JSON,
|
||||
// scripts/online.py), else the ones in the user folder.
|
||||
const char* user = std::getenv("BB_USER_DIR");
|
||||
const std::string dir = std::string{user != nullptr && *user != '\0' ? user : "user"} + "/";
|
||||
std::vector<std::string> candidates{dir + "host_overrides.custom.json", dir + "host_overrides.json"};
|
||||
if (const char* given = std::getenv("SHADPS4_HTTP_HOST_OVERRIDES_JSON"); given && *given) {
|
||||
candidates.insert(candidates.begin(), given);
|
||||
}
|
||||
#endif
|
||||
for (const auto& path : candidates) {
|
||||
host = HostFromOverrides(ReadFile(path));
|
||||
if (!host.empty()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ReadAll(SOCKET s, void* data, int size) {
|
||||
auto* at = static_cast<char*>(data);
|
||||
while (size > 0) {
|
||||
const int got = recv(s, at, size, 0);
|
||||
if (got <= 0) {
|
||||
return false;
|
||||
}
|
||||
at += got;
|
||||
size -= got;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Our session as the relay sees it: hosting -> ourselves, a guest (or joining) -> our host.
|
||||
u32 SessionKey() {
|
||||
const s32 state = Core::Bloodborne::GetBbscRelaySessionState();
|
||||
if (state == 3) {
|
||||
return own_id;
|
||||
}
|
||||
if (state == 4 || state == 6) {
|
||||
return Core::Bloodborne::GetBbscRelayHost();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Returns how many players it went to: the relay's count, or (no relay connected - public servers)
|
||||
// the players the P2P channel sent it to (bbsc_channel.cpp).
|
||||
u32 SendFrame(const void* data, u32 size) {
|
||||
std::scoped_lock lock{send_mutex};
|
||||
const u32 key = SessionKey();
|
||||
const u32 wrapped = static_cast<u32>(EnvelopeSize) + size;
|
||||
std::vector<char> frame(4 + wrapped);
|
||||
std::memcpy(frame.data(), &wrapped, 4);
|
||||
std::memcpy(frame.data() + 4, "SHAD", 4);
|
||||
frame[8] = static_cast<char>(EnvelopeType);
|
||||
std::memcpy(frame.data() + 9, &own_id, 4);
|
||||
std::memcpy(frame.data() + 13, &key, 4);
|
||||
std::memcpy(frame.data() + 4 + EnvelopeSize, data, size);
|
||||
if (relay == INVALID_SOCKET) {
|
||||
return BbscMod::P2PChannelSend(frame.data() + 4, wrapped);
|
||||
}
|
||||
int left = static_cast<int>(frame.size());
|
||||
const char* at = frame.data();
|
||||
while (left > 0) {
|
||||
const int sent = send(relay, at, left, 0);
|
||||
if (sent <= 0) {
|
||||
closesocket(relay);
|
||||
relay = INVALID_SOCKET;
|
||||
return BbscMod::P2PChannelSend(frame.data() + 4, wrapped);
|
||||
}
|
||||
at += sent;
|
||||
left -= sent;
|
||||
}
|
||||
return static_cast<u32>(peers.load());
|
||||
}
|
||||
|
||||
void Dispatch(const std::vector<u8>& wrapped) {
|
||||
if (wrapped.size() == 2 && wrapped[0] == PeersType) {
|
||||
peers = wrapped[1];
|
||||
return;
|
||||
}
|
||||
if (wrapped.size() < 5 || std::memcmp(wrapped.data(), "SHAD", 4) != 0) {
|
||||
return;
|
||||
}
|
||||
if (wrapped[4] != EnvelopeType || wrapped.size() < EnvelopeSize + 5) {
|
||||
if (!logged_old_sender.exchange(true)) {
|
||||
Log("a player on this server runs an older BBSC (packets without a sender) - their "
|
||||
"packets are ignored until they update");
|
||||
}
|
||||
return;
|
||||
}
|
||||
u32 sender = 0;
|
||||
u32 session = 0;
|
||||
std::memcpy(&sender, wrapped.data() + 5, 4);
|
||||
std::memcpy(&session, wrapped.data() + 9, 4);
|
||||
const std::vector<u8> frame(wrapped.begin() + EnvelopeSize, wrapped.end());
|
||||
if (sender == own_id || std::memcmp(frame.data(), "SHAD", 4) != 0) {
|
||||
return;
|
||||
}
|
||||
const s32 state = Core::Bloodborne::GetBbscRelaySessionState();
|
||||
const bool guest = state == 4 || state == 6;
|
||||
const bool sender_hosting = session != 0 && session == sender;
|
||||
// Host-only packets are taken from our host alone.
|
||||
const auto from_our_host = [&] {
|
||||
return guest && sender_hosting && sender == Core::Bloodborne::GetBbscRelayHost();
|
||||
};
|
||||
if (frame[4] == FollowType && frame.size() >= 0x1E) {
|
||||
FollowPacket follow{};
|
||||
std::memcpy(&follow, frame.data(), std::min(frame.size(), sizeof(follow)));
|
||||
if (sender_hosting) {
|
||||
Core::Bloodborne::NoteBbscRelayHost(sender, follow.packed_map, follow.x, follow.y,
|
||||
follow.z);
|
||||
}
|
||||
if (from_our_host()) {
|
||||
Core::Bloodborne::OnBbscHostFollowPacket(
|
||||
follow.packed_map, follow.x, follow.y, follow.z, follow.heading, follow.sequence,
|
||||
frame.size() >= sizeof(follow) ? follow.lamp_point : -1);
|
||||
}
|
||||
} else if (frame[4] == LifeType && frame.size() >= 14) {
|
||||
// Only the players of our own session (same host).
|
||||
if (session == 0 || session != SessionKey()) {
|
||||
return;
|
||||
}
|
||||
u32 player = 0;
|
||||
u32 sequence = 0;
|
||||
std::memcpy(&player, frame.data() + 5, 4);
|
||||
std::memcpy(&sequence, frame.data() + 10, 4);
|
||||
Core::Bloodborne::OnBbscLifePacket(player, frame[9], sequence);
|
||||
} else if (frame[4] == ScaleType && frame.size() >= 15) {
|
||||
if (from_our_host()) {
|
||||
std::uint16_t percent[5]{};
|
||||
std::memcpy(percent, frame.data() + 5, sizeof(percent));
|
||||
Core::Bloodborne::OnBbscScalePacket(percent);
|
||||
}
|
||||
} else if (frame[4] == PvpType && frame.size() >= 11) {
|
||||
// Kind 0 is the host's state (for its guests), kind 1 a guest asking its host.
|
||||
const bool ours = frame[5] == 0 ? from_our_host() : state == 3 && session == own_id;
|
||||
if (ours) {
|
||||
Core::Bloodborne::OnBbscPvpPacket(frame[5], frame[6]);
|
||||
}
|
||||
} else if (frame[4] == ProgressType && frame.size() >= 6) {
|
||||
// The host's boss flags for shared progress: u8 count + count x u32.
|
||||
const size_t count = std::min<size_t>(frame[5], (frame.size() - 6) / 4);
|
||||
if (from_our_host() && count != 0) {
|
||||
std::vector<std::uint32_t> flags(count);
|
||||
std::memcpy(flags.data(), frame.data() + 6, count * 4);
|
||||
Core::Bloodborne::OnBbscProgressPacket(flags.data(), flags.size());
|
||||
}
|
||||
} else if (frame[4] == HostBossType && frame.size() >= 9) {
|
||||
if (from_our_host()) {
|
||||
std::int32_t boss = -1;
|
||||
std::memcpy(&boss, frame.data() + 5, 4);
|
||||
Core::Bloodborne::OnBbscHostBossPacket(boss);
|
||||
}
|
||||
} else if (frame[4] == PlacementType) {
|
||||
// A host looking for signs: for players not in a session yet, any host on the server.
|
||||
BbscMod::OnHostPlacement(std::string(frame.begin() + 5, frame.end()), sender);
|
||||
} else if (frame[4] == Libraries::Np::NpSignaling::kSignalingBbscWorldFlagsType) {
|
||||
if (from_our_host()) {
|
||||
Core::Bloodborne::OnBbscWorldFlagsPacket(frame.data(), static_cast<u32>(frame.size()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DWORD WINAPI RelayThread(void*) {
|
||||
WSADATA wsa{};
|
||||
WSAStartup(MAKEWORD(2, 2), &wsa);
|
||||
std::string last_problem;
|
||||
for (;;) {
|
||||
std::string host;
|
||||
u16 port = 0;
|
||||
if (!FindServer(host, port)) {
|
||||
if (last_problem != "no server") {
|
||||
last_problem = "no server";
|
||||
Log("no game server address (host_overrides.json / BBSC_RELAY) - retrying");
|
||||
}
|
||||
Sleep(10'000);
|
||||
continue;
|
||||
}
|
||||
if (BbscMod::IsPublicServerAddress(host)) {
|
||||
// The Hunter's Dream / NoDream run no BBSC relay: BBSC packets take the P2P channel.
|
||||
if (last_problem != "public") {
|
||||
last_problem = "public";
|
||||
Log("public game server (" + host + ") - no relay there; BBSC packets go over "
|
||||
"the P2P channel");
|
||||
}
|
||||
Sleep(30'000);
|
||||
continue;
|
||||
}
|
||||
addrinfo hints{};
|
||||
hints.ai_family = AF_INET;
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
addrinfo* found = nullptr;
|
||||
SOCKET s = INVALID_SOCKET;
|
||||
if (getaddrinfo(host.c_str(), std::to_string(port).c_str(), &hints, &found) == 0) {
|
||||
s = socket(found->ai_family, found->ai_socktype, found->ai_protocol);
|
||||
if (s != INVALID_SOCKET && connect(s, found->ai_addr, static_cast<int>(found->ai_addrlen)) != 0) {
|
||||
closesocket(s);
|
||||
s = INVALID_SOCKET;
|
||||
}
|
||||
freeaddrinfo(found);
|
||||
}
|
||||
if (s == INVALID_SOCKET) {
|
||||
const std::string problem = "cannot reach " + host + ":" + std::to_string(port);
|
||||
if (problem != last_problem) {
|
||||
last_problem = problem;
|
||||
Log(problem + " (game server not started, or an older one without the relay) - "
|
||||
"retrying every 5 s");
|
||||
}
|
||||
Sleep(5'000);
|
||||
continue;
|
||||
}
|
||||
BOOL on = TRUE;
|
||||
setsockopt(s, IPPROTO_TCP, TCP_NODELAY, reinterpret_cast<const char*>(&on), sizeof(on));
|
||||
// Frames are sent from the game thread (review 2026-10-08): a stalled server must not
|
||||
// freeze the game in send() - after 300 ms the frame fails and the link is rebuilt.
|
||||
const DWORD send_timeout_ms = 300;
|
||||
setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast<const char*>(&send_timeout_ms),
|
||||
sizeof(send_timeout_ms));
|
||||
{
|
||||
std::scoped_lock lock{send_mutex};
|
||||
relay = s;
|
||||
}
|
||||
last_problem.clear();
|
||||
Log("connected to " + host + ":" + std::to_string(port));
|
||||
for (;;) {
|
||||
u32 size = 0;
|
||||
if (!ReadAll(s, &size, 4) || size > 64 * 1024) {
|
||||
break;
|
||||
}
|
||||
std::vector<u8> frame(size);
|
||||
if (size != 0 && !ReadAll(s, frame.data(), static_cast<int>(size))) {
|
||||
break;
|
||||
}
|
||||
std::scoped_lock dispatch_lock{dispatch_mutex};
|
||||
Dispatch(frame);
|
||||
}
|
||||
{
|
||||
std::scoped_lock lock{send_mutex};
|
||||
if (relay == s) {
|
||||
closesocket(relay);
|
||||
relay = INVALID_SOCKET;
|
||||
}
|
||||
}
|
||||
peers = 0;
|
||||
Log("connection lost - reconnecting");
|
||||
Sleep(2'000);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace BbscMod {
|
||||
// Public game server (The Hunter's Dream...): it works like Sony's, so BBSC must summon the vanilla
|
||||
// way (bbsc_mod.cpp Worker). No host_overrides.json found = not public (review 2026-10-11): a
|
||||
// self-host install whose file BBSC does not look for must keep the seamless summons, and without
|
||||
// the file the game reaches no server at all anyway.
|
||||
bool GameServerIsPublic(std::string* host_out) {
|
||||
std::string host;
|
||||
u16 port = 0;
|
||||
if (!FindServer(host, port)) {
|
||||
if (host_out != nullptr) {
|
||||
*host_out = "none found";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (host_out != nullptr) {
|
||||
*host_out = host;
|
||||
}
|
||||
return BbscMod::IsPublicServerAddress(host);
|
||||
}
|
||||
|
||||
// Frames the P2P channel received (same format as the relay's, without the length).
|
||||
void RelayDispatch(const std::vector<u8>& wrapped) {
|
||||
std::scoped_lock lock{dispatch_mutex};
|
||||
Dispatch(wrapped);
|
||||
}
|
||||
|
||||
void RelaySendPlacement(const std::string& placement) {
|
||||
std::string frame = "SHAD";
|
||||
frame.push_back(static_cast<char>(PlacementType));
|
||||
frame += placement;
|
||||
SendFrame(frame.data(), static_cast<u32>(frame.size()));
|
||||
}
|
||||
|
||||
void StartRelay() {
|
||||
if (!started.exchange(true)) {
|
||||
#ifdef _WIN32
|
||||
LARGE_INTEGER counter{};
|
||||
QueryPerformanceCounter(&counter);
|
||||
const u64 ticks = static_cast<u64>(counter.QuadPart);
|
||||
#else
|
||||
timespec now{};
|
||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
const u64 ticks = static_cast<u64>(now.tv_sec) * 1'000'000'000ull + static_cast<u64>(now.tv_nsec);
|
||||
#endif
|
||||
own_id = static_cast<u32>((ticks ^ GetCurrentProcessId()) * 2654435761ull) | 1u;
|
||||
if (HANDLE thread = CreateThread(nullptr, 0, RelayThread, nullptr, 0, nullptr)) {
|
||||
CloseHandle(thread);
|
||||
}
|
||||
}
|
||||
}
|
||||
} // namespace BbscMod
|
||||
|
||||
namespace Libraries::Np::NpSignaling::Helpers {
|
||||
|
||||
u32 SendBbscFollowToPeers(u32 packed_map, float x, float y, float z, float heading, u32 sequence,
|
||||
s32 lamp_point) {
|
||||
FollowPacket packet{};
|
||||
packet.packed_map = packed_map;
|
||||
packet.x = x;
|
||||
packet.y = y;
|
||||
packet.z = z;
|
||||
packet.heading = heading;
|
||||
packet.sequence = sequence;
|
||||
packet.lamp_point = lamp_point;
|
||||
return SendFrame(&packet, sizeof(packet));
|
||||
}
|
||||
|
||||
u32 SendBbscWorldFlagsToPeers(const void* packet, u32 size) {
|
||||
return SendFrame(packet, size);
|
||||
}
|
||||
|
||||
} // namespace Libraries::Np::NpSignaling::Helpers
|
||||
@@ -0,0 +1,223 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// bbport: the Linux counterpart of emulator/mod/bbsc_hooks.cpp - Core::InstallGuestCodeHook for
|
||||
// libbbsc.so. The trampoline is the mod's, unchanged: save every register and YMM into a
|
||||
// GuestRegisterSnapshot, call the C++ hook with it, restore the (possibly changed) snapshot, run the
|
||||
// copied instructions, jump back. The trampolines live in 1 MB of our own, mapped within a rel32
|
||||
// jump's reach below the game's image (bbport has no emulator trampoline area after it; the image
|
||||
// grows upwards, and bbport's own hooks take pages below it the same way).
|
||||
|
||||
#include <sys/mman.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
|
||||
#include <xbyak/xbyak.h>
|
||||
|
||||
#include "core/cpu_patches.h"
|
||||
|
||||
namespace BbscMod {
|
||||
void LogPlain(const char* text);
|
||||
u64 EbootAddress();
|
||||
}
|
||||
|
||||
namespace Core {
|
||||
namespace {
|
||||
|
||||
using namespace Xbyak::util;
|
||||
|
||||
constexpr u64 OurSize = 1ULL << 20;
|
||||
|
||||
std::mutex hook_mutex;
|
||||
Xbyak::CodeGenerator* generator = nullptr;
|
||||
bool area_checked = false;
|
||||
bool area_ok = false;
|
||||
|
||||
// 1 MB below the image, within jump reach of all of it, never over anything already mapped.
|
||||
u64 MapOwnArea(u64 image) {
|
||||
for (u64 k = 1; k <= 256; ++k) {
|
||||
const u64 hint = image - k * (8ULL << 20);
|
||||
void* got = mmap(reinterpret_cast<void*>(hint), OurSize, PROT_READ | PROT_WRITE | PROT_EXEC,
|
||||
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, -1, 0);
|
||||
if (got != MAP_FAILED) {
|
||||
return reinterpret_cast<u64>(got);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool CheckArea() {
|
||||
if (area_checked) {
|
||||
return area_ok;
|
||||
}
|
||||
area_checked = true;
|
||||
const u64 image = BbscMod::EbootAddress();
|
||||
if (image == 0) {
|
||||
BbscMod::LogPlain("Hook area: the game image was not found - no hooks");
|
||||
return false;
|
||||
}
|
||||
const u64 start = MapOwnArea(image);
|
||||
if (start == 0) {
|
||||
BbscMod::LogPlain("Hook area: no free memory within reach of the game image - no hooks");
|
||||
return false;
|
||||
}
|
||||
generator = new Xbyak::CodeGenerator(OurSize, reinterpret_cast<void*>(start));
|
||||
area_ok = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// The 5-byte jump (and NOPs to the end of the covered instructions): all but the first byte, then
|
||||
// the first, so a thread meeting the site decodes either the old instruction start or the jump.
|
||||
bool WriteJump(u8* site, const void* target, size_t length) {
|
||||
const auto page = static_cast<u64>(sysconf(_SC_PAGESIZE));
|
||||
void* first = reinterpret_cast<void*>(reinterpret_cast<u64>(site) & ~(page - 1));
|
||||
const size_t span = reinterpret_cast<u64>(site) + length - reinterpret_cast<u64>(first);
|
||||
if (mprotect(first, span, PROT_READ | PROT_WRITE | PROT_EXEC) != 0) {
|
||||
return false;
|
||||
}
|
||||
std::array<u8, 32> bytes{};
|
||||
const s64 rel = reinterpret_cast<s64>(target) - reinterpret_cast<s64>(site + 5);
|
||||
bytes[0] = 0xE9;
|
||||
const s32 rel32 = static_cast<s32>(rel);
|
||||
std::memcpy(bytes.data() + 1, &rel32, 4);
|
||||
for (size_t i = 5; i < length; ++i) {
|
||||
bytes[i] = 0x90;
|
||||
}
|
||||
for (size_t i = 1; i < length; ++i) {
|
||||
__atomic_store_n(site + i, bytes[i], __ATOMIC_SEQ_CST);
|
||||
}
|
||||
__atomic_store_n(site, bytes[0], __ATOMIC_SEQ_CST);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool InstallGuestCodeHook(void* address, std::span<const u8> expected_instructions, u64 tag,
|
||||
GuestCodeHook hook, const u8* skip_flag) {
|
||||
constexpr size_t NearJumpBytes = 5;
|
||||
constexpr size_t GuestRedZoneBytes = 128;
|
||||
constexpr size_t SnapshotOffset = 0;
|
||||
constexpr size_t ScratchR11Offset = sizeof(GuestRegisterSnapshot);
|
||||
constexpr size_t ScratchRflagsOffset = ScratchR11Offset + sizeof(u64);
|
||||
constexpr size_t FrameReserve =
|
||||
((ScratchRflagsOffset + sizeof(u64) + GuestRedZoneBytes) + 15) & ~size_t{15};
|
||||
|
||||
auto* code = static_cast<u8*>(address);
|
||||
if (hook == nullptr || expected_instructions.size() < NearJumpBytes ||
|
||||
expected_instructions.size() > 32) {
|
||||
return false;
|
||||
}
|
||||
std::scoped_lock lock{hook_mutex};
|
||||
if (!CheckArea()) {
|
||||
return false;
|
||||
}
|
||||
if (std::memcmp(expected_instructions.data(), code, expected_instructions.size()) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
auto& trampoline = *generator;
|
||||
const size_t trampoline_offset = trampoline.getSize();
|
||||
const auto* trampoline_start = trampoline.getCurr();
|
||||
const auto snapshot = [](size_t field_offset) {
|
||||
return qword[rsp + SnapshotOffset + field_offset];
|
||||
};
|
||||
|
||||
try {
|
||||
trampoline.lea(rsp, ptr[rsp - FrameReserve]);
|
||||
trampoline.mov(qword[rsp + ScratchR11Offset], r11);
|
||||
trampoline.pushfq();
|
||||
trampoline.pop(r11);
|
||||
trampoline.mov(qword[rsp + ScratchRflagsOffset], r11);
|
||||
trampoline.mov(r11, rsp);
|
||||
trampoline.and_(rsp, -16);
|
||||
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rax)), rax);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rbx)), rbx);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rcx)), rcx);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rdx)), rdx);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rsi)), rsi);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rdi)), rdi);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rbp)), rbp);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r8)), r8);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r9)), r9);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r10)), r10);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r12)), r12);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r13)), r13);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r14)), r14);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r15)), r15);
|
||||
trampoline.mov(rax, qword[r11 + ScratchR11Offset]);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r11)), rax);
|
||||
trampoline.mov(rax, qword[r11 + ScratchRflagsOffset]);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rflags)), rax);
|
||||
trampoline.lea(rax, ptr[r11 + FrameReserve]);
|
||||
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rsp)), rax);
|
||||
trampoline.stmxcsr(dword[rsp + SnapshotOffset + offsetof(GuestRegisterSnapshot, mxcsr)]);
|
||||
for (size_t index = 0; index < 16; ++index) {
|
||||
const size_t offset = SnapshotOffset + offsetof(GuestRegisterSnapshot, ymm) +
|
||||
index * sizeof(std::array<u8, 32>);
|
||||
trampoline.vmovdqu(yword[rsp + offset], Xbyak::Ymm(static_cast<int>(index)));
|
||||
}
|
||||
|
||||
// The hook is Windows code: 32 bytes of shadow space and the MS x64 arguments are not
|
||||
// what a PS4_SYSV_ABI function expects, so it is called the SysV way (rdi, rsi), exactly
|
||||
// as the emulator does. A 32-byte gap below the snapshot keeps either convention safe.
|
||||
trampoline.mov(r12, rsp);
|
||||
trampoline.sub(rsp, 32);
|
||||
trampoline.mov(rdi, tag);
|
||||
trampoline.lea(rsi, ptr[r12 + SnapshotOffset]);
|
||||
trampoline.mov(rax, reinterpret_cast<u64>(hook));
|
||||
trampoline.call(rax);
|
||||
trampoline.mov(rsp, r12);
|
||||
|
||||
for (size_t index = 0; index < 16; ++index) {
|
||||
const size_t offset = SnapshotOffset + offsetof(GuestRegisterSnapshot, ymm) +
|
||||
index * sizeof(std::array<u8, 32>);
|
||||
trampoline.vmovdqu(Xbyak::Ymm(static_cast<int>(index)), yword[rsp + offset]);
|
||||
}
|
||||
trampoline.ldmxcsr(dword[rsp + SnapshotOffset + offsetof(GuestRegisterSnapshot, mxcsr)]);
|
||||
trampoline.mov(rbx, snapshot(offsetof(GuestRegisterSnapshot, rbx)));
|
||||
trampoline.mov(rcx, snapshot(offsetof(GuestRegisterSnapshot, rcx)));
|
||||
trampoline.mov(rdx, snapshot(offsetof(GuestRegisterSnapshot, rdx)));
|
||||
trampoline.mov(rsi, snapshot(offsetof(GuestRegisterSnapshot, rsi)));
|
||||
trampoline.mov(rdi, snapshot(offsetof(GuestRegisterSnapshot, rdi)));
|
||||
trampoline.mov(rbp, snapshot(offsetof(GuestRegisterSnapshot, rbp)));
|
||||
trampoline.mov(r8, snapshot(offsetof(GuestRegisterSnapshot, r8)));
|
||||
trampoline.mov(r9, snapshot(offsetof(GuestRegisterSnapshot, r9)));
|
||||
trampoline.mov(r10, snapshot(offsetof(GuestRegisterSnapshot, r10)));
|
||||
trampoline.mov(r11, snapshot(offsetof(GuestRegisterSnapshot, r11)));
|
||||
trampoline.mov(r12, snapshot(offsetof(GuestRegisterSnapshot, r12)));
|
||||
trampoline.mov(r13, snapshot(offsetof(GuestRegisterSnapshot, r13)));
|
||||
trampoline.mov(r14, snapshot(offsetof(GuestRegisterSnapshot, r14)));
|
||||
trampoline.mov(r15, snapshot(offsetof(GuestRegisterSnapshot, r15)));
|
||||
trampoline.mov(rax, snapshot(offsetof(GuestRegisterSnapshot, rflags)));
|
||||
trampoline.push(rax);
|
||||
trampoline.popfq();
|
||||
trampoline.mov(rax, snapshot(offsetof(GuestRegisterSnapshot, rax)));
|
||||
trampoline.mov(rsp, snapshot(offsetof(GuestRegisterSnapshot, rsp)));
|
||||
Xbyak::Label run_original;
|
||||
if (skip_flag != nullptr) {
|
||||
// Function entry only: a plain ret skips the whole function (see cpu_patches.h).
|
||||
trampoline.push(rax);
|
||||
trampoline.mov(rax, reinterpret_cast<u64>(skip_flag));
|
||||
trampoline.cmp(byte[rax], 0);
|
||||
trampoline.mov(byte[rax], 0);
|
||||
trampoline.pop(rax);
|
||||
trampoline.je(run_original, Xbyak::CodeGenerator::LabelType::T_NEAR);
|
||||
trampoline.ret();
|
||||
}
|
||||
trampoline.L(run_original);
|
||||
trampoline.db(expected_instructions.data(), expected_instructions.size());
|
||||
trampoline.jmp(code + expected_instructions.size(), Xbyak::CodeGenerator::LabelType::T_NEAR);
|
||||
} catch (const Xbyak::Error& error) {
|
||||
trampoline.setSize(trampoline_offset);
|
||||
BbscMod::LogPlain(error.what());
|
||||
return false;
|
||||
}
|
||||
return WriteJump(code, trampoline_start, expected_instructions.size());
|
||||
}
|
||||
|
||||
} // namespace Core
|
||||
@@ -0,0 +1,585 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// bbport: the Linux counterpart of emulator/mod/bbsc_mod.cpp - BBSC as a library (libbbsc.so)
|
||||
// that bb-probe loads into the game's process (BB_BBSC_LIB) instead of a DLL injected into
|
||||
// shadPS4. The steps are the DLL's, and the patch site logic below is its code unchanged:
|
||||
// 1. wait until every byte patch site reads back as the bytes that must be there (all of them
|
||||
// before writing any - another game or version is left alone);
|
||||
// 2. write the byte patches (core/bbsc_patch_tables.h);
|
||||
// 3. install every hook with core/bloodborne_re.cpp, compiled into this library with BBSC_MOD;
|
||||
// 4. keep watching: a patch site written back is written again.
|
||||
// What differs on Linux: the game is always at 0x800000000 (bbport, as the shadp2p fork); memory
|
||||
// questions are answered from bbport's own map of the game's memory (runtime_memory_vma_info, no
|
||||
// system call); the log goes to <user dir>/bbsc/logs/ and its warnings to the game's log; the F1 /
|
||||
// L3+R3 cinematic camera and BBSC's own Discord status are not in this build (bbport has its own
|
||||
// Discord status, and L3+R3 opens bbport's menu).
|
||||
|
||||
#include <dlfcn.h>
|
||||
#include <sys/mman.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cstdarg>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "linux/bbsc_win32.h"
|
||||
|
||||
#include "common/logging/log.h"
|
||||
#include "common/memory_patcher.h"
|
||||
#include "common/path_util.h"
|
||||
#include "core/bbsc_cinematic.h"
|
||||
#include "core/bbsc_patch_tables.h"
|
||||
#include "core/bloodborne_re.h"
|
||||
#include "core/debugger.h"
|
||||
#include "core/libraries/np/np_matching2/np_matching2_mm.h"
|
||||
#include "core/memory.h"
|
||||
|
||||
// bbport's runtime (bb-probe exports it): the game's memory map.
|
||||
extern "C" int runtime_memory_vma_info(uintptr_t address, int* prot, int* type, uintptr_t* end);
|
||||
|
||||
using namespace Core::Bbsc::PatchTables;
|
||||
|
||||
// Keep it equal to "version" in mod.json and to the Windows mod it plays with.
|
||||
#define BBSC_VERSION "0.0.1"
|
||||
|
||||
namespace BbscMod {
|
||||
void LogPlain(const char* text);
|
||||
u64 EbootAddress();
|
||||
void StartRelay();
|
||||
void StartP2PChannel(); // bbsc_channel.cpp
|
||||
bool GameServerIsPublic(std::string* host_out); // bbsc_relay.cpp
|
||||
void InstallHttpWrappers();
|
||||
} // namespace BbscMod
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr u64 ForkEbootBase = 0x800000000ULL; // bbport and the shadp2p fork
|
||||
u64 EbootBase = 0;
|
||||
constexpr u64 EbootSize = 0x056D4000ULL; // CUSA03173 01.09 image size
|
||||
// The image's code (its first PT_LOAD): made writable as shadPS4 maps it, since the shared hook
|
||||
// code writes some code bytes directly.
|
||||
constexpr u64 EbootCodeSize = 0x050DC000ULL;
|
||||
constexpr DWORD WaitForImageMs = 180'000;
|
||||
constexpr DWORD SettleMs = 20;
|
||||
constexpr DWORD WatchMs = 500;
|
||||
|
||||
std::filesystem::path mod_dir; // where libbbsc.so and its LICENSE/NOTICE/SUPPORTERS are
|
||||
std::filesystem::path data_dir; // <user dir>/bbsc: logs, captures
|
||||
FILE* log_file = nullptr;
|
||||
std::mutex log_mutex;
|
||||
|
||||
// The newest `keep` files of a folder with this extension stay; older ones (earlier game starts)
|
||||
// go. Each start writes a log and a capture of its own.
|
||||
void KeepNewest(const std::filesystem::path& folder, const char* extension, size_t keep) {
|
||||
std::error_code ignored;
|
||||
std::vector<std::filesystem::directory_entry> files;
|
||||
for (const auto& entry : std::filesystem::directory_iterator(folder, ignored)) {
|
||||
if (entry.is_regular_file(ignored) && entry.path().extension() == extension) {
|
||||
files.push_back(entry);
|
||||
}
|
||||
}
|
||||
if (files.size() <= keep) {
|
||||
return;
|
||||
}
|
||||
std::sort(files.begin(), files.end(), [](const auto& a, const auto& b) {
|
||||
std::error_code e;
|
||||
return a.last_write_time(e) > b.last_write_time(e);
|
||||
});
|
||||
for (size_t i = keep; i < files.size(); ++i) {
|
||||
std::filesystem::remove(files[i].path(), ignored);
|
||||
}
|
||||
}
|
||||
|
||||
void OpenLog() {
|
||||
Dl_info info{};
|
||||
if (dladdr(reinterpret_cast<void*>(&OpenLog), &info) != 0 && info.dli_fname != nullptr) {
|
||||
mod_dir = std::filesystem::path{info.dli_fname}.parent_path();
|
||||
}
|
||||
// The game's user folder (run.sh exports it as BB_GPU_USER_DIR). Not the working directory's
|
||||
// "user": in the AppImage that is inside its read-only mount, and BBSC installs none of its
|
||||
// hooks without a capture folder (InstallReverseEngineeringTrace) - with its death patch
|
||||
// still in, a death then never reloads. The temporary folder when the user folder is not
|
||||
// writable either.
|
||||
const char* user = std::getenv("BB_USER_DIR");
|
||||
if (user == nullptr || *user == '\0') {
|
||||
user = std::getenv("BB_GPU_USER_DIR");
|
||||
}
|
||||
const std::filesystem::path user_dir{user != nullptr && *user != '\0' ? user : "user"};
|
||||
data_dir = user_dir / "bbsc";
|
||||
std::error_code ignored;
|
||||
// BBSC looks for its saves in shadPS4's layout under the US id (user/home/<user>/savedata/
|
||||
// CUSA00207/SPRJ0005); bbport keeps them in user/savedata/<user>/<the game's id>/SPRJ0005 (EU
|
||||
// CUSA00900 and the others). Without them its kit is never given (it backs the saves up first)
|
||||
// and the own-world, loot and progress features have no save slot: point BBSC_SAVE_DIR, BBSC's
|
||||
// own setting for a save folder elsewhere, at the most recently written one.
|
||||
if (const char* set = std::getenv("BBSC_SAVE_DIR"); set == nullptr || *set == '\0') {
|
||||
std::filesystem::path newest;
|
||||
std::filesystem::file_time_type newest_time{};
|
||||
try { // a directory walk's ++ throws even with an error_code
|
||||
for (const auto& owner : std::filesystem::directory_iterator(user_dir / "savedata", ignored)) {
|
||||
for (const auto& title : std::filesystem::directory_iterator(owner.path(), ignored)) {
|
||||
const auto dir = title.path() / "SPRJ0005";
|
||||
const auto written = std::filesystem::last_write_time(dir, ignored);
|
||||
if (!ignored && (newest.empty() || written > newest_time)) {
|
||||
newest = dir;
|
||||
newest_time = written;
|
||||
}
|
||||
ignored.clear();
|
||||
}
|
||||
ignored.clear();
|
||||
}
|
||||
} catch (const std::exception&) {
|
||||
}
|
||||
ignored.clear();
|
||||
if (!newest.empty()) {
|
||||
setenv("BBSC_SAVE_DIR", newest.c_str(), 1);
|
||||
}
|
||||
}
|
||||
std::filesystem::create_directories(data_dir / "captures" / "bloodborne-re", ignored);
|
||||
if (ignored) {
|
||||
data_dir = std::filesystem::temp_directory_path(ignored) / "bbport-bbsc";
|
||||
std::filesystem::create_directories(data_dir / "captures" / "bloodborne-re", ignored);
|
||||
}
|
||||
std::filesystem::create_directories(data_dir / "logs", ignored);
|
||||
KeepNewest(data_dir / "logs", ".log", 4); // with this one, 5
|
||||
KeepNewest(data_dir / "captures" / "bloodborne-re", ".jsonl", 4);
|
||||
const std::time_t now = std::time(nullptr);
|
||||
std::tm local{};
|
||||
localtime_r(&now, &local);
|
||||
char name[96];
|
||||
std::snprintf(name, sizeof(name), "bbsc-mod-%04d%02d%02d-%02d%02d%02d-%d.log",
|
||||
local.tm_year + 1900, local.tm_mon + 1, local.tm_mday, local.tm_hour,
|
||||
local.tm_min, local.tm_sec, static_cast<int>(getpid()));
|
||||
log_file = std::fopen((data_dir / "logs" / name).c_str(), "w");
|
||||
std::printf("BBSC: log %s\n", (data_dir / "logs" / name).c_str());
|
||||
}
|
||||
|
||||
// ---- Memory --------------------------------------------------------------------------------------
|
||||
|
||||
// The game's own memory is in bbport's map; anything else (the image, host memory a game pointer
|
||||
// should never reach) is checked with mincore, which fails on an unmapped page.
|
||||
bool HostMapped(u64 address, size_t size) {
|
||||
const u64 page = static_cast<u64>(sysconf(_SC_PAGESIZE));
|
||||
const u64 first = address & ~(page - 1);
|
||||
const u64 last = (address + size - 1) & ~(page - 1);
|
||||
std::vector<unsigned char> pages((last - first) / page + 1);
|
||||
return mincore(reinterpret_cast<void*>(first), last - first + page, pages.data()) == 0;
|
||||
}
|
||||
|
||||
bool Readable(u64 address, size_t size) {
|
||||
if (address == 0 || address + size < address) {
|
||||
return false;
|
||||
}
|
||||
if (EbootBase != 0 && address >= EbootBase && address + size <= EbootBase + EbootSize) {
|
||||
return true;
|
||||
}
|
||||
u64 at = address;
|
||||
const u64 end = address + size;
|
||||
while (at < end) {
|
||||
int prot = 0, type = 0;
|
||||
uintptr_t region_end = 0;
|
||||
if (!runtime_memory_vma_info(at, &prot, &type, ®ion_end)) {
|
||||
return HostMapped(at, end - at);
|
||||
}
|
||||
if ((prot & 1) == 0) { // PS4 CPU read
|
||||
return false;
|
||||
}
|
||||
at = region_end;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MatchesAt(u64 base, u64 offset, const u8* bytes, size_t size) {
|
||||
const u64 address = base + offset;
|
||||
return Readable(address, size) &&
|
||||
std::memcmp(reinterpret_cast<const void*>(address), bytes, size) == 0;
|
||||
}
|
||||
|
||||
bool Matches(u64 offset, const u8* bytes, size_t size) {
|
||||
const u64 address = EbootBase + offset;
|
||||
return Readable(address, size) &&
|
||||
std::memcmp(reinterpret_cast<const void*>(address), bytes, size) == 0;
|
||||
}
|
||||
|
||||
void Write(u64 offset, const u8* bytes, size_t size) {
|
||||
void* address = reinterpret_cast<void*>(EbootBase + offset);
|
||||
DWORD old = 0;
|
||||
if (!VirtualProtect(address, size, PAGE_EXECUTE_READWRITE, &old)) {
|
||||
return;
|
||||
}
|
||||
std::memcpy(address, bytes, size);
|
||||
}
|
||||
|
||||
// ---- Byte patches (bbsc_mod.cpp, unchanged) ------------------------------------------------------
|
||||
|
||||
struct Site {
|
||||
const char* name;
|
||||
u64 offset;
|
||||
std::array<u8, 6> original{};
|
||||
std::array<u8, 6> patched{};
|
||||
size_t size;
|
||||
Group group;
|
||||
};
|
||||
|
||||
template <size_t N>
|
||||
std::array<u8, 6> Pad(const std::array<u8, N>& bytes) {
|
||||
std::array<u8, 6> out{};
|
||||
std::memcpy(out.data(), bytes.data(), N);
|
||||
return out;
|
||||
}
|
||||
|
||||
constexpr size_t SiteCount =
|
||||
Retargets.size() + EntryReturns.size() + ByteEdits.size() + WideEdits.size() + EntryJumps.size();
|
||||
std::array<Site, SiteCount> sites{};
|
||||
std::mutex patch_mutex;
|
||||
bool patches_ready = false;
|
||||
bool session_on = true; // session groups: off while a suspension runs
|
||||
bool role_on = false; // role groups (lamp): on while hosting
|
||||
|
||||
void BuildSites() {
|
||||
size_t i = 0;
|
||||
for (const auto& e : Retargets) {
|
||||
sites[i++] = {e.name, e.site, Pad(EncodeRel32(e.opcode, e.site, e.original)),
|
||||
Pad(EncodeRel32(e.opcode, e.site, e.target)), 5, e.group};
|
||||
}
|
||||
for (const auto& e : EntryReturns) {
|
||||
std::array<u8, 6> ret{};
|
||||
ret[0] = 0xC3;
|
||||
sites[i++] = {e.name, e.site, e.prologue, ret, 1, e.group};
|
||||
}
|
||||
for (const auto& e : ByteEdits) {
|
||||
sites[i++] = {e.name, e.site, Pad(e.original), Pad(e.replacement), 2, e.group};
|
||||
}
|
||||
for (const auto& e : WideEdits) {
|
||||
sites[i++] = {e.name, e.site, Pad(e.original), Pad(e.replacement), 4, e.group};
|
||||
}
|
||||
for (const auto& e : EntryJumps) {
|
||||
sites[i++] = {e.name, e.site, Pad(e.prologue), Pad(EncodeRel32(0xE9, e.site, e.target)), 5,
|
||||
e.group};
|
||||
}
|
||||
}
|
||||
|
||||
bool AllSitesKnown(const Site** first_bad) {
|
||||
for (const Site& s : sites) {
|
||||
if (!GroupEnabled(s.group)) {
|
||||
continue;
|
||||
}
|
||||
if (s.offset + s.size > EbootSize ||
|
||||
(!Matches(s.offset, s.original.data(), s.size) &&
|
||||
!Matches(s.offset, s.patched.data(), s.size))) {
|
||||
*first_bad = &s;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Wanted(const Site& s) {
|
||||
if (!GroupEnabled(s.group)) {
|
||||
return false;
|
||||
}
|
||||
if (IsRoleGroup(s.group)) {
|
||||
return role_on;
|
||||
}
|
||||
if (IsSessionGroup(s.group)) {
|
||||
return session_on;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int SyncSites() {
|
||||
int count = 0;
|
||||
for (const Site& s : sites) {
|
||||
if (!GroupEnabled(s.group)) {
|
||||
continue;
|
||||
}
|
||||
const auto& want = Wanted(s) ? s.patched : s.original;
|
||||
if (!Matches(s.offset, want.data(), s.size)) {
|
||||
Write(s.offset, want.data(), s.size);
|
||||
++count;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ---- Start-up ------------------------------------------------------------------------------------
|
||||
|
||||
// BBSC's license, notice and supporters travel with it (next to libbbsc.so); missing or changed,
|
||||
// BBSC stays off and says why, as the Windows mod does.
|
||||
bool NoticesPresent() {
|
||||
const auto holds = [](const char* name, const char* text) {
|
||||
FILE* file = std::fopen((mod_dir / name).c_str(), "rb");
|
||||
if (file == nullptr) {
|
||||
return false;
|
||||
}
|
||||
std::string content(64 * 1024, '\0');
|
||||
content.resize(std::fread(content.data(), 1, content.size(), file));
|
||||
std::fclose(file);
|
||||
return content.find(text) != std::string::npos;
|
||||
};
|
||||
return holds("LICENSE.txt", "GNU GENERAL PUBLIC LICENSE") &&
|
||||
holds("NOTICE.txt", "github.com/Classmg22/BBSC") &&
|
||||
holds("SUPPORTERS.txt", "BBSC SUPPORTERS");
|
||||
}
|
||||
|
||||
DWORD WINAPI Worker(void*) {
|
||||
OpenLog();
|
||||
BbscMod::LogPlain("BBSC mod loaded - version " BBSC_VERSION " (bbport, Linux)");
|
||||
if (!NoticesPresent()) {
|
||||
LOG_ERROR(Mod, "LICENSE.txt / NOTICE.txt / SUPPORTERS.txt missing or changed in {} - BBSC "
|
||||
"is off",
|
||||
mod_dir.string());
|
||||
return 0;
|
||||
}
|
||||
LOG_INFO(Mod, "BBSC mod in pid {}", static_cast<int>(getpid()));
|
||||
if (const char* saves = std::getenv("BBSC_SAVE_DIR"); saves != nullptr && *saves != '\0') {
|
||||
LOG_INFO(Mod, "Saves: {}", saves);
|
||||
} else {
|
||||
LOG_WARNING(Mod, "Saves: none found - no co-op kit, own world or shared progress");
|
||||
}
|
||||
{
|
||||
std::string server;
|
||||
if (BbscMod::GameServerIsPublic(&server)) {
|
||||
setenv("BBSC_MOD_SEAMLESS", "0", 1);
|
||||
setenv("SHADPS4_BBSC_FIVE_HUNTERS", "0", 1);
|
||||
LOG_INFO(Mod, "Public game server ({}): vanilla summons - seamless summon patches and the "
|
||||
"5-hunter search off (the session itself stays seamless)",
|
||||
server);
|
||||
}
|
||||
}
|
||||
BuildSites();
|
||||
|
||||
// bbport loads the game at the fork's address before any of its code runs.
|
||||
EbootBase = ForkEbootBase;
|
||||
const DWORD started = GetTickCount();
|
||||
if (mprotect(reinterpret_cast<void*>(EbootBase), EbootCodeSize,
|
||||
PROT_READ | PROT_WRITE | PROT_EXEC) == 0) {
|
||||
LOG_INFO(Mod, "Game code made writable, as shadPS4 maps it");
|
||||
}
|
||||
const Site* bad = nullptr;
|
||||
while (!AllSitesKnown(&bad)) {
|
||||
if (GetTickCount() - started > WaitForImageMs) {
|
||||
LOG_ERROR(Mod,
|
||||
"No Bloodborne CUSA03173 01.09 image after {} s (first unknown site: {} at "
|
||||
"{:#x}) - not this game or not this version; nothing was changed",
|
||||
WaitForImageMs / 1000, bad ? bad->name : "?", bad ? bad->offset : 0);
|
||||
return 0;
|
||||
}
|
||||
Sleep(1);
|
||||
}
|
||||
Sleep(SettleMs);
|
||||
const DWORD verified = GetTickCount() - started;
|
||||
|
||||
MemoryPatcher::g_eboot_address = EbootBase;
|
||||
MemoryPatcher::g_eboot_image_size = EbootSize;
|
||||
MemoryPatcher::g_game_serial = "CUSA03173";
|
||||
|
||||
// Wozzardman's seamless patches first: one of them (0x019471B1) is also in BBSC's list, and it
|
||||
// checks for the original bytes.
|
||||
Core::Bloodborne::InstallSeamlessCoopPatches();
|
||||
|
||||
int written = 0;
|
||||
{
|
||||
std::scoped_lock lock{patch_mutex};
|
||||
patches_ready = true;
|
||||
written = SyncSites();
|
||||
}
|
||||
LOG_INFO(Mod, "Image verified after {} ms: {} byte patch sites written", verified, written);
|
||||
|
||||
Core::Bloodborne::InstallReverseEngineeringTrace();
|
||||
BbscMod::StartRelay();
|
||||
BbscMod::StartP2PChannel();
|
||||
BbscMod::InstallHttpWrappers();
|
||||
LOG_INFO(Mod, "BBSC mod ready after {} ms", GetTickCount() - started);
|
||||
|
||||
for (;;) {
|
||||
Sleep(WatchMs);
|
||||
std::scoped_lock lock{patch_mutex};
|
||||
if (const int redone = SyncSites(); redone != 0) {
|
||||
LOG_WARNING(Mod, "{} patch site(s) had changed back - written again", redone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ---- Logging -------------------------------------------------------------------------------------
|
||||
|
||||
namespace BbscMod {
|
||||
|
||||
void LogLine(char level, const char* category, const std::string& text) {
|
||||
std::scoped_lock lock{log_mutex};
|
||||
// The game's log gets the mod's own lines and every warning or error.
|
||||
if (level == 'W' || level == 'E' || std::strcmp(category, "Mod") == 0) {
|
||||
std::printf("BBSC: %c %s: %s\n", level, category, text.c_str());
|
||||
}
|
||||
if (log_file == nullptr) {
|
||||
return;
|
||||
}
|
||||
timespec now{};
|
||||
clock_gettime(CLOCK_REALTIME, &now);
|
||||
std::tm local{};
|
||||
localtime_r(&now.tv_sec, &local);
|
||||
std::fprintf(log_file, "[%02d:%02d:%02d.%03ld] %c %s: %s\n", local.tm_hour, local.tm_min,
|
||||
local.tm_sec, now.tv_nsec / 1'000'000, level, category, text.c_str());
|
||||
std::fflush(log_file);
|
||||
}
|
||||
|
||||
void LogPlain(const char* text) {
|
||||
LogLine('I', "Mod", text);
|
||||
}
|
||||
|
||||
u64 EbootAddress() {
|
||||
return EbootBase;
|
||||
}
|
||||
|
||||
// Import slots swapped for wrappers (bbsc_http.cpp, bbsc_channel.cpp): put back if anything links
|
||||
// them again (bbport links the imports once, before the game runs; kept as the Windows mod has it).
|
||||
namespace {
|
||||
struct GuardedSlot {
|
||||
u64 slot_offset;
|
||||
u64 wrapper;
|
||||
void** original;
|
||||
const char* name;
|
||||
int rewritten = 0;
|
||||
};
|
||||
std::mutex guarded_mutex;
|
||||
std::vector<GuardedSlot> guarded_slots;
|
||||
|
||||
DWORD WINAPI SlotGuardThread(void*) {
|
||||
for (;;) {
|
||||
Sleep(5);
|
||||
std::scoped_lock lock{guarded_mutex};
|
||||
for (auto& guarded : guarded_slots) {
|
||||
auto* slot = reinterpret_cast<volatile u64*>(EbootBase + guarded.slot_offset);
|
||||
const u64 value = *slot;
|
||||
if (value == guarded.wrapper || value == 0 ||
|
||||
(value >= EbootBase && value < EbootBase + EbootSize)) {
|
||||
continue;
|
||||
}
|
||||
*reinterpret_cast<void* volatile*>(guarded.original) = reinterpret_cast<void*>(value);
|
||||
DWORD old = 0;
|
||||
VirtualProtect(const_cast<u64*>(slot), 8, PAGE_READWRITE, &old);
|
||||
*slot = guarded.wrapper;
|
||||
if (++guarded.rewritten <= 3) {
|
||||
LOG_INFO(Mod, "Import {} was linked again - wrapper put back ({}x)", guarded.name,
|
||||
guarded.rewritten);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void GuardSlot(u64 slot_offset, u64 wrapper, void** original, const char* name) {
|
||||
std::scoped_lock lock{guarded_mutex};
|
||||
if (guarded_slots.empty()) {
|
||||
CreateThread(nullptr, 0, SlotGuardThread, nullptr, 0, nullptr);
|
||||
}
|
||||
guarded_slots.push_back({slot_offset, wrapper, original, name});
|
||||
}
|
||||
|
||||
void StartDiscordStatus() {} // bbport has its own Discord status
|
||||
|
||||
} // namespace BbscMod
|
||||
|
||||
// ---- What bloodborne_re.cpp asks the emulator for ----------------------------------------------
|
||||
|
||||
namespace Core {
|
||||
|
||||
bool MemoryManager::IsValidMapping(u64 address, u64 size) {
|
||||
return Readable(address, size == 0 ? 1 : size);
|
||||
}
|
||||
|
||||
// The PS4 protection bits (1 read, 2 write, 4 execute) of the region at address. bbport's map
|
||||
// gives where the region ends; its start is reported as the address's own page.
|
||||
s32 MemoryManager::QueryProtection(u64 address, void** start, void** end, u32* prot) {
|
||||
const u64 page = static_cast<u64>(sysconf(_SC_PAGESIZE));
|
||||
if (EbootBase != 0 && address >= EbootBase && address < EbootBase + EbootSize) {
|
||||
*start = reinterpret_cast<void*>(EbootBase);
|
||||
*end = reinterpret_cast<void*>(EbootBase + EbootSize);
|
||||
*prot = address < EbootBase + EbootCodeSize ? 7u : 3u;
|
||||
return 0;
|
||||
}
|
||||
int region_prot = 0, type = 0;
|
||||
uintptr_t region_end = 0;
|
||||
if (!runtime_memory_vma_info(address, ®ion_prot, &type, ®ion_end)) {
|
||||
return -1;
|
||||
}
|
||||
*start = reinterpret_cast<void*>(address & ~(page - 1));
|
||||
*end = reinterpret_cast<void*>(region_end);
|
||||
*prot = static_cast<u32>(region_prot) & 7u;
|
||||
return 0;
|
||||
}
|
||||
|
||||
namespace Debugger {
|
||||
int GetCurrentPid() {
|
||||
return static_cast<int>(getpid());
|
||||
}
|
||||
} // namespace Debugger
|
||||
|
||||
namespace Bbsc {
|
||||
|
||||
void InstallPersistentSessionPatches() {} // applied by Worker
|
||||
|
||||
void SetPersistentSessionPatches(bool enabled) {
|
||||
std::scoped_lock lock{patch_mutex};
|
||||
if (!patches_ready || session_on == enabled) {
|
||||
return;
|
||||
}
|
||||
session_on = enabled;
|
||||
const int count = SyncSites();
|
||||
LOG_INFO(Debug, "BBSC persistence {} ({} session sites; echoes, lamp, fog walls and deaths "
|
||||
"untouched)",
|
||||
enabled ? "re-applied" : "suspended", count);
|
||||
}
|
||||
|
||||
void SetHostLampPatches(bool hosting) {
|
||||
std::scoped_lock lock{patch_mutex};
|
||||
if (!patches_ready || role_on == hosting) {
|
||||
return;
|
||||
}
|
||||
role_on = hosting;
|
||||
const int count = SyncSites();
|
||||
LOG_INFO(Debug, "BBSC lamp: {} ({} sites)",
|
||||
hosting ? "prompt allowed (hosting a session)"
|
||||
: "vanilla gates (solo or guest - a guest never gets the lamp)",
|
||||
count);
|
||||
}
|
||||
|
||||
// The cinematic camera is not in the Linux build: it stays off.
|
||||
CinematicInput TakeCinematicInput() {
|
||||
return CinematicInput{};
|
||||
}
|
||||
void CinematicForceOff() {}
|
||||
void CinematicForceOn() {}
|
||||
|
||||
} // namespace Bbsc
|
||||
} // namespace Core
|
||||
|
||||
namespace Common::FS {
|
||||
std::filesystem::path GetUserPath(PathType type) {
|
||||
return type == PathType::CapturesDir ? data_dir / "captures" : data_dir;
|
||||
}
|
||||
} // namespace Common::FS
|
||||
|
||||
namespace Libraries::Np::NpMatching2 {
|
||||
int BbscReplayRoomMembers(bool) {
|
||||
return 0; // the online module's room cache is out of reach here
|
||||
}
|
||||
} // namespace Libraries::Np::NpMatching2
|
||||
|
||||
// bb-probe calls this once the game is loaded, linked and patched, before it runs any of it.
|
||||
extern "C" __attribute__((visibility("default"))) void bbsc_start(void) {
|
||||
CreateThread(nullptr, 0, Worker, nullptr, 0, nullptr);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// bbport: the handful of Win32 and Winsock names the BBSC mod uses, on Linux. BSD sockets under
|
||||
// their Winsock names (as bbbridge's port), POSIX threads, mprotect, a monotonic clock. Only what
|
||||
// the mod's files call; anything that differs in meaning is handled in the file itself (#ifdef).
|
||||
#pragma once
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <cerrno>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <netdb.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netinet/tcp.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/socket.h>
|
||||
#include <thread>
|
||||
#include <unistd.h>
|
||||
|
||||
using DWORD = std::uint32_t;
|
||||
using BOOL = int;
|
||||
using HANDLE = void*;
|
||||
using LPVOID = void*;
|
||||
using SOCKET = int;
|
||||
#ifndef TRUE
|
||||
#define TRUE 1
|
||||
#define FALSE 0
|
||||
#endif
|
||||
#define WINAPI
|
||||
#define MAX_PATH 4096
|
||||
#define INVALID_SOCKET (-1)
|
||||
#define SOCKET_ERROR (-1)
|
||||
#define INVALID_HANDLE_VALUE (reinterpret_cast<HANDLE>(-1))
|
||||
|
||||
// Memory protection: only the constants the mod passes.
|
||||
constexpr DWORD PAGE_NOACCESS = 0x01, PAGE_READONLY = 0x02, PAGE_READWRITE = 0x04,
|
||||
PAGE_EXECUTE = 0x10, PAGE_EXECUTE_READ = 0x20, PAGE_EXECUTE_READWRITE = 0x40;
|
||||
|
||||
inline int BbscPosixProt(DWORD protect) {
|
||||
switch (protect) {
|
||||
case PAGE_NOACCESS:
|
||||
return PROT_NONE;
|
||||
case PAGE_READONLY:
|
||||
return PROT_READ;
|
||||
case PAGE_READWRITE:
|
||||
return PROT_READ | PROT_WRITE;
|
||||
case PAGE_EXECUTE:
|
||||
case PAGE_EXECUTE_READ:
|
||||
return PROT_READ | PROT_EXEC;
|
||||
default:
|
||||
return PROT_READ | PROT_WRITE | PROT_EXEC;
|
||||
}
|
||||
}
|
||||
|
||||
// The pages under [address, address + size) get `protect`. The previous protection is not known
|
||||
// here: it is reported as read/write/execute, which is what restoring it then sets (the game's
|
||||
// image is made read/write/execute at start anyway, as shadPS4 maps it).
|
||||
inline BOOL VirtualProtect(void* address, std::size_t size, DWORD protect, DWORD* old) {
|
||||
const auto page = static_cast<std::uintptr_t>(sysconf(_SC_PAGESIZE));
|
||||
const auto start = reinterpret_cast<std::uintptr_t>(address) & ~(page - 1);
|
||||
const auto end = reinterpret_cast<std::uintptr_t>(address) + size;
|
||||
if (old != nullptr) {
|
||||
*old = PAGE_EXECUTE_READWRITE;
|
||||
}
|
||||
return mprotect(reinterpret_cast<void*>(start), end - start, BbscPosixProt(protect)) == 0;
|
||||
}
|
||||
|
||||
inline HANDLE GetCurrentProcess() {
|
||||
return nullptr;
|
||||
}
|
||||
// x86: instruction fetches see stores without a flush.
|
||||
inline BOOL FlushInstructionCache(HANDLE, const void*, std::size_t) {
|
||||
return TRUE;
|
||||
}
|
||||
inline DWORD GetCurrentProcessId() {
|
||||
return static_cast<DWORD>(getpid());
|
||||
}
|
||||
|
||||
inline std::uint64_t GetTickCount64() {
|
||||
timespec now{};
|
||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
return static_cast<std::uint64_t>(now.tv_sec) * 1000 + static_cast<std::uint64_t>(now.tv_nsec) / 1'000'000;
|
||||
}
|
||||
inline DWORD GetTickCount() {
|
||||
return static_cast<DWORD>(GetTickCount64());
|
||||
}
|
||||
inline void Sleep(DWORD ms) {
|
||||
timespec span{static_cast<time_t>(ms / 1000), static_cast<long>(ms % 1000) * 1'000'000};
|
||||
while (nanosleep(&span, &span) != 0 && errno == EINTR) {
|
||||
}
|
||||
}
|
||||
|
||||
// A detached thread running a DWORD WINAPI f(void*); the handle only says it started.
|
||||
inline HANDLE CreateThread(void*, std::size_t, DWORD (*start)(void*), void* argument, DWORD,
|
||||
DWORD*) {
|
||||
try {
|
||||
std::thread([start, argument] { start(argument); }).detach();
|
||||
} catch (...) {
|
||||
return nullptr;
|
||||
}
|
||||
return reinterpret_cast<HANDLE>(1);
|
||||
}
|
||||
inline BOOL CloseHandle(HANDLE) {
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Winsock.
|
||||
struct WSADATA {};
|
||||
#define MAKEWORD(a, b) static_cast<unsigned short>((a) | ((b) << 8))
|
||||
inline int WSAStartup(unsigned short, WSADATA*) {
|
||||
return 0;
|
||||
}
|
||||
// shutdown first: a thread blocked in recv on the socket wakes up (close alone leaves it there).
|
||||
inline int closesocket(SOCKET s) {
|
||||
shutdown(s, SHUT_RDWR);
|
||||
return close(s);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"id": "bbsc",
|
||||
"name": "BBSC - Bloodborne Seamless Co-op",
|
||||
"version": "0.0.1",
|
||||
"author": "CLS_AMG; seamless summon by Wozzardman; server format by fhody125",
|
||||
"description": "One co-op session across areas, deaths, lamps, bosses and the Hunter's Dream. Bloodborne CUSA03173 v1.09 only. Warps and world sync go through the host's game server relay (port 20444). Off = the vanilla online game.",
|
||||
"games": ["CUSA03173"],
|
||||
"dll": "bbsc.dll",
|
||||
"enabled_by_default": true,
|
||||
"settings": [
|
||||
{"id": "seamless", "name": "Seamless summon", "description": "Summon without the vanilla signs and waits (Wozzardman)", "type": "bool", "default": true, "env": "BBSC_MOD_SEAMLESS"},
|
||||
{"id": "hooks", "name": "Co-op hooks", "description": "Warps, world sync, Hunter's Dream, messages... (most features below need this)", "type": "bool", "default": true, "env": "BBSC_MOD_HOOKS"},
|
||||
{"id": "connect_first", "name": "Cross-map summon", "description": "Join first, then one load into the host's map", "type": "bool", "default": true, "env": "SHADPS4_BBSC_CONNECT_FIRST"},
|
||||
{"id": "warp_together", "name": "Warp together", "description": "The guest follows the host through lamp warps", "type": "bool", "default": true, "env": "SHADPS4_BBSC_WARP_TOGETHER"},
|
||||
{"id": "world_sync", "name": "World sync", "description": "The guest sees the host's world state (doors, bosses, lifts)", "type": "bool", "default": true, "env": "SHADPS4_BBSC_WORLD_SYNC"},
|
||||
{"id": "dream", "name": "Hunter's Dream together", "description": "The guest uses the Doll, workshop and messengers", "type": "bool", "default": true, "env": "SHADPS4_BBSC_DREAM"},
|
||||
{"id": "no_fog_walls", "name": "No co-op fog walls", "description": "Leaving the area is allowed", "type": "bool", "default": true, "env": "SHADPS4_BBSC_NO_FOG_WALLS"},
|
||||
{"id": "lamp", "name": "Lamps in co-op", "description": "The host can use lamps during a session", "type": "bool", "default": true, "env": "SHADPS4_BBSC_LAMP"},
|
||||
{"id": "host_dead", "name": "Stay when the host dies", "description": "The guest keeps the session if the host dies", "type": "bool", "default": true, "env": "SHADPS4_BBSC_PERSIST_HOSTDEAD"},
|
||||
{"id": "spectate", "name": "Respawn in place", "description": "A dead player respawns / spectates instead of leaving", "type": "bool", "default": true, "env": "SHADPS4_BBSC_SPECTATE"},
|
||||
{"id": "full_echoes", "name": "Full echoes", "description": "A guest is paid the host's rate, not half", "type": "bool", "default": true, "env": "SHADPS4_BBSC_FULL_ECHOES"},
|
||||
{"id": "no_hp_penalty", "name": "No guest HP cut", "description": "Removes the summoned guest's 30% max HP cut", "type": "bool", "default": true, "env": "SHADPS4_BBSC_NO_HP_PENALTY"},
|
||||
{"id": "guest_items", "name": "Guest items", "description": "A guest picks up and keeps items", "type": "bool", "default": true, "env": "SHADPS4_BBSC_GUEST_ITEMS"},
|
||||
{"id": "solid_npcs", "name": "Solid NPCs", "description": "A guest sees NPCs solid, not as grey ghosts", "type": "bool", "default": true, "env": "SHADPS4_BBSC_SOLID_NPCS"},
|
||||
{"id": "death_penalty", "name": "Bloodstain on death", "description": "A kept death still drops a bloodstain and echoes", "type": "bool", "default": true, "env": "SHADPS4_BBSC_DEATH_PENALTY"},
|
||||
{"id": "free_bell", "name": "Free Beckoning Bell", "description": "The bell costs no Insight", "type": "bool", "default": true, "env": "SHADPS4_BBSC_FREE_BELL"},
|
||||
{"id": "five_hunters", "name": "Five hunters", "description": "The host may summon up to 4 guests", "type": "bool", "default": true, "env": "SHADPS4_BBSC_FIVE_HUNTERS"},
|
||||
{"id": "messages", "name": "Co-op texts", "description": "Texts that fit seamless play (no \"Returning to your world\")", "type": "bool", "default": true, "env": "SHADPS4_BBSC_MESSAGES"},
|
||||
{"id": "load_cover", "name": "Black fade on joins", "description": "The guest's loads happen behind a black fade", "type": "bool", "default": true, "env": "SHADPS4_BBSC_LOAD_COVER"},
|
||||
{"id": "floor_guard", "name": "Floor guard", "description": "No crash when a character's floor was unloaded", "type": "bool", "default": true, "env": "SHADPS4_BBSC_FLOOR_GUARD"},
|
||||
{"id": "scaling", "name": "Enemy scaling", "description": "Enemies and bosses get stronger for every guest (replaces the game's own boss buff)", "type": "bool", "default": true, "env": "SHADPS4_BBSC_SCALING"},
|
||||
{"id": "scale_npc_hunters", "name": "Scale NPC hunters", "description": "Henryk, Djura and the other hostile hunters also get the enemy scaling (off = vanilla)", "type": "bool", "default": false, "env": "BBSC_SCALE_NPC_HUNTERS"},
|
||||
{"id": "scale_enemy_hp", "name": "Enemy HP per guest (%)", "description": "Extra health for normal enemies, per guest", "type": "int", "default": 40, "min": 0, "max": 300, "env": "BBSC_SCALE_ENEMY_HP"},
|
||||
{"id": "scale_boss_hp", "name": "Boss HP per guest (%)", "description": "Extra health for bosses, per guest", "type": "int", "default": 100, "min": 0, "max": 300, "env": "BBSC_SCALE_BOSS_HP"},
|
||||
{"id": "scale_damage", "name": "Enemy damage per guest (%)", "description": "Extra damage for enemies and bosses, per guest", "type": "int", "default": 15, "min": 0, "max": 200, "env": "BBSC_SCALE_DAMAGE"},
|
||||
{"id": "scale_enemy_stagger", "name": "Enemy stagger resistance per guest (%)", "description": "Harder to stagger normal enemies, per guest", "type": "int", "default": 15, "min": 0, "max": 200, "env": "BBSC_SCALE_ENEMY_STAGGER"},
|
||||
{"id": "scale_boss_stagger", "name": "Boss stagger resistance per guest (%)", "description": "Harder to stagger bosses, per guest", "type": "int", "default": 20, "min": 0, "max": 200, "env": "BBSC_SCALE_BOSS_STAGGER"},
|
||||
{"id": "shared_progress", "name": "Shared progress", "description": "When a guest goes home, bosses, shortcuts, doors and lamps the host has done are added to the guest's own world (adds only, never removes; NPC quests and your character are not touched; saves backed up first)", "type": "bool", "default": true, "env": "SHADPS4_BBSC_SHARED_PROGRESS"},
|
||||
{"id": "discord", "name": "Discord status", "description": "Shows BBSC, your area, boss fights and the number of hunters on your Discord profile", "type": "bool", "default": true, "env": "SHADPS4_BBSC_DISCORD"},
|
||||
{"id": "p2p_channel", "name": "P2P channel", "description": "Sends BBSC's own messages (follow, world sync, scaling, leaving) straight to the other players over the game's own connection when there is no BBSC relay - needed on public servers (The Hunter's Dream)", "type": "bool", "default": true, "env": "SHADPS4_BBSC_P2P_CHANNEL"},
|
||||
{"id": "relay", "name": "Relay address", "description": "host:20444 - leave empty to use the game server address from host_overrides.json (JOIN-HOST sets it)", "type": "text", "default": "", "env": "BBSC_RELAY"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// BBSC mod: stand-in for the emulator's logging. Same macros, written to bbsc-mod.log.
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <fmt/format.h>
|
||||
|
||||
namespace BbscMod {
|
||||
void LogLine(char level, const char* category, const std::string& text);
|
||||
}
|
||||
|
||||
#define LOG_TRACE(cat, ...) ::BbscMod::LogLine('T', #cat, fmt::format(__VA_ARGS__))
|
||||
#define LOG_DEBUG(cat, ...) ::BbscMod::LogLine('D', #cat, fmt::format(__VA_ARGS__))
|
||||
#define LOG_INFO(cat, ...) ::BbscMod::LogLine('I', #cat, fmt::format(__VA_ARGS__))
|
||||
#define LOG_WARNING(cat, ...) ::BbscMod::LogLine('W', #cat, fmt::format(__VA_ARGS__))
|
||||
#define LOG_ERROR(cat, ...) ::BbscMod::LogLine('E', #cat, fmt::format(__VA_ARGS__))
|
||||
#define LOG_CRITICAL(cat, ...) ::BbscMod::LogLine('C', #cat, fmt::format(__VA_ARGS__))
|
||||
@@ -0,0 +1,11 @@
|
||||
// BBSC mod: the three values the emulator's MemoryPatcher knows about the game, set by the DLL
|
||||
// once it has verified the image (bbsc_mod.cpp).
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace MemoryPatcher {
|
||||
inline std::uintptr_t g_eboot_address = 0;
|
||||
inline std::uint64_t g_eboot_image_size = 0;
|
||||
inline std::string g_game_serial;
|
||||
} // namespace MemoryPatcher
|
||||
@@ -0,0 +1,13 @@
|
||||
// BBSC mod: paths. The "user" folder is the DLL's own folder.
|
||||
#pragma once
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
namespace Common::FS {
|
||||
enum class PathType { UserDir, LogDir, CapturesDir };
|
||||
std::filesystem::path GetUserPath(PathType type);
|
||||
inline std::string PathToUTF8String(const std::filesystem::path& path) {
|
||||
const auto text = path.u8string();
|
||||
return std::string(text.begin(), text.end());
|
||||
}
|
||||
} // namespace Common::FS
|
||||
@@ -0,0 +1,5 @@
|
||||
// BBSC mod: process id for the capture file name.
|
||||
#pragma once
|
||||
namespace Core::Debugger {
|
||||
int GetCurrentPid();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// BBSC mod: the game's param.sfo is not read; the DLL only runs on an image whose patch sites all
|
||||
// match CUSA03173 01.09, so that is what it reports.
|
||||
#pragma once
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
|
||||
class PSF {
|
||||
public:
|
||||
std::optional<std::string_view> GetString(std::string_view key) const {
|
||||
if (key == "APP_VER") {
|
||||
return "01.09";
|
||||
}
|
||||
if (key == "TITLE_ID") {
|
||||
return "CUSA03173";
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
// BBSC mod: the emulator's room-member replay is not reachable from a mod.
|
||||
#pragma once
|
||||
namespace Libraries::Np::NpMatching2 {
|
||||
int BbscReplayRoomMembers(bool announce_left);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
// BBSC packets on the np_signaling socket that the Bloodborne code also builds and reads. Kept out
|
||||
// of np_signaling_state.h, which pulls in the platform headers (their `near`/`far` macros break
|
||||
// bloodborne_re.cpp).
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
#include "common/types.h"
|
||||
|
||||
namespace Libraries::Np::NpSignaling {
|
||||
|
||||
inline constexpr u8 kSignalingBbscWorldFlagsType = 0x22;
|
||||
|
||||
// One chunk of a host world-flag snapshot: up to 8 event-flag groups (1000 flags, 125 bytes each)
|
||||
// of the host's current map block, in the fixed order of Core::Bloodborne's BbscWorldGroupKey.
|
||||
// ~1 KB, below the 1280-byte tunnel MTU (Tailscale) with the UDP and v-port headers.
|
||||
inline constexpr size_t kBbscWorldFlagGroupBytes = 125;
|
||||
inline constexpr size_t kBbscWorldFlagGroupsPerChunk = 8;
|
||||
|
||||
#pragma pack(push, 1)
|
||||
struct SignalingBbscWorldFlags {
|
||||
u8 magic[4] = {'S', 'H', 'A', 'D'};
|
||||
u8 type = kSignalingBbscWorldFlagsType;
|
||||
u8 version = 1;
|
||||
u32 packed_map = 0;
|
||||
u32 snapshot = 0; // the host's snapshot counter: chunks of one snapshot belong together
|
||||
u8 chunk = 0; // index of this chunk
|
||||
u8 chunk_count = 0; // chunks in the snapshot
|
||||
u8 present = 0; // bit n: group n of this chunk exists in the host's flag table
|
||||
u8 group_count = 0; // groups carried (the last chunk may carry fewer than 8)
|
||||
u8 groups[kBbscWorldFlagGroupsPerChunk][kBbscWorldFlagGroupBytes]{};
|
||||
};
|
||||
static_assert(sizeof(SignalingBbscWorldFlags) == 18 + 8 * 125);
|
||||
#pragma pack(pop)
|
||||
|
||||
} // namespace Libraries::Np::NpSignaling
|
||||
@@ -0,0 +1,10 @@
|
||||
// BBSC mod: BBSC's own packets go through the BBSC relay (bbsc_relay.cpp), not the emulator's
|
||||
// signaling. Same functions, same meaning.
|
||||
#pragma once
|
||||
#include "common/types.h"
|
||||
|
||||
namespace Libraries::Np::NpSignaling::Helpers {
|
||||
u32 SendBbscFollowToPeers(u32 packed_map, float x, float y, float z, float heading, u32 sequence,
|
||||
s32 lamp_point);
|
||||
u32 SendBbscWorldFlagsToPeers(const void* packet, u32 size);
|
||||
} // namespace Libraries::Np::NpSignaling::Helpers
|
||||
@@ -0,0 +1,27 @@
|
||||
// BBSC mod: stand-in for the emulator's memory manager. The game's memory sits at its guest
|
||||
// addresses inside the emulator's process, so Windows' VirtualQuery answers what the emulator's
|
||||
// own map would.
|
||||
#pragma once
|
||||
#include "common/enum.h"
|
||||
#include "common/singleton.h"
|
||||
#include "common/types.h"
|
||||
|
||||
namespace Core {
|
||||
|
||||
enum class MemoryProt : u32 {
|
||||
NoAccess = 0,
|
||||
CpuRead = 1,
|
||||
CpuWrite = 2,
|
||||
CpuReadWrite = 3,
|
||||
CpuExec = 4,
|
||||
};
|
||||
DECLARE_ENUM_FLAG_OPERATORS(MemoryProt)
|
||||
|
||||
class MemoryManager {
|
||||
public:
|
||||
bool IsValidMapping(u64 address, u64 size = 0);
|
||||
s32 QueryProtection(u64 address, void** start, void** end, u32* prot);
|
||||
};
|
||||
using Memory = Common::Singleton<MemoryManager>;
|
||||
|
||||
} // namespace Core
|
||||
@@ -0,0 +1,161 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2024 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <type_traits>
|
||||
#include "common/types.h"
|
||||
|
||||
#define DECLARE_ENUM_FLAG_OPERATORS(type) \
|
||||
[[nodiscard]] constexpr type operator|(type a, type b) noexcept { \
|
||||
using T = std::underlying_type_t<type>; \
|
||||
return static_cast<type>(static_cast<T>(a) | static_cast<T>(b)); \
|
||||
} \
|
||||
[[nodiscard]] constexpr type operator&(type a, type b) noexcept { \
|
||||
using T = std::underlying_type_t<type>; \
|
||||
return static_cast<type>(static_cast<T>(a) & static_cast<T>(b)); \
|
||||
} \
|
||||
[[nodiscard]] constexpr type operator^(type a, type b) noexcept { \
|
||||
using T = std::underlying_type_t<type>; \
|
||||
return static_cast<type>(static_cast<T>(a) ^ static_cast<T>(b)); \
|
||||
} \
|
||||
[[nodiscard]] constexpr type operator<<(type a, type b) noexcept { \
|
||||
using T = std::underlying_type_t<type>; \
|
||||
return static_cast<type>(static_cast<T>(a) << static_cast<T>(b)); \
|
||||
} \
|
||||
[[nodiscard]] constexpr type operator>>(type a, type b) noexcept { \
|
||||
using T = std::underlying_type_t<type>; \
|
||||
return static_cast<type>(static_cast<T>(a) >> static_cast<T>(b)); \
|
||||
} \
|
||||
constexpr type& operator|=(type& a, type b) noexcept { \
|
||||
a = a | b; \
|
||||
return a; \
|
||||
} \
|
||||
constexpr type& operator&=(type& a, type b) noexcept { \
|
||||
a = a & b; \
|
||||
return a; \
|
||||
} \
|
||||
constexpr type& operator^=(type& a, type b) noexcept { \
|
||||
a = a ^ b; \
|
||||
return a; \
|
||||
} \
|
||||
constexpr type& operator<<=(type& a, type b) noexcept { \
|
||||
a = a << b; \
|
||||
return a; \
|
||||
} \
|
||||
constexpr type& operator>>=(type& a, type b) noexcept { \
|
||||
a = a >> b; \
|
||||
return a; \
|
||||
} \
|
||||
[[nodiscard]] constexpr type operator~(type key) noexcept { \
|
||||
using T = std::underlying_type_t<type>; \
|
||||
return static_cast<type>(~static_cast<T>(key)); \
|
||||
} \
|
||||
[[nodiscard]] constexpr bool True(type key) noexcept { \
|
||||
using T = std::underlying_type_t<type>; \
|
||||
return static_cast<T>(key) != 0; \
|
||||
} \
|
||||
[[nodiscard]] constexpr bool False(type key) noexcept { \
|
||||
using T = std::underlying_type_t<type>; \
|
||||
return static_cast<T>(key) == 0; \
|
||||
}
|
||||
|
||||
namespace Common {
|
||||
|
||||
template <typename T>
|
||||
class Flags {
|
||||
public:
|
||||
using IntType = std::underlying_type_t<T>;
|
||||
|
||||
Flags() {}
|
||||
|
||||
Flags(IntType t) : m_bits(t) {}
|
||||
|
||||
template <typename... Tx>
|
||||
Flags(T f, Tx... fx) {
|
||||
this->set(f, fx...);
|
||||
}
|
||||
|
||||
template <typename... Tx>
|
||||
void set(Tx... fx) {
|
||||
m_bits |= bits(fx...);
|
||||
}
|
||||
|
||||
void set(Flags flags) {
|
||||
m_bits |= flags.m_bits;
|
||||
}
|
||||
|
||||
template <typename... Tx>
|
||||
void clr(Tx... fx) {
|
||||
m_bits &= ~bits(fx...);
|
||||
}
|
||||
|
||||
void clr(Flags flags) {
|
||||
m_bits &= ~flags.m_bits;
|
||||
}
|
||||
|
||||
template <typename... Tx>
|
||||
bool any(Tx... fx) const {
|
||||
return (m_bits & bits(fx...)) != 0;
|
||||
}
|
||||
|
||||
template <typename... Tx>
|
||||
bool all(Tx... fx) const {
|
||||
const IntType mask = bits(fx...);
|
||||
return (m_bits & mask) == mask;
|
||||
}
|
||||
|
||||
bool test(T f) const {
|
||||
return this->any(f);
|
||||
}
|
||||
|
||||
bool isClear() const {
|
||||
return m_bits == 0;
|
||||
}
|
||||
|
||||
void clrAll() {
|
||||
m_bits = 0;
|
||||
}
|
||||
|
||||
u32 raw() const {
|
||||
return m_bits;
|
||||
}
|
||||
|
||||
Flags operator&(const Flags& other) const {
|
||||
return Flags(m_bits & other.m_bits);
|
||||
}
|
||||
|
||||
Flags operator|(const Flags& other) const {
|
||||
return Flags(m_bits | other.m_bits);
|
||||
}
|
||||
|
||||
Flags operator^(const Flags& other) const {
|
||||
return Flags(m_bits ^ other.m_bits);
|
||||
}
|
||||
|
||||
bool operator==(const Flags& other) const {
|
||||
return m_bits == other.m_bits;
|
||||
}
|
||||
|
||||
bool operator!=(const Flags& other) const {
|
||||
return m_bits != other.m_bits;
|
||||
}
|
||||
|
||||
private:
|
||||
IntType m_bits = 0;
|
||||
|
||||
static IntType bit(T f) {
|
||||
return IntType(1) << static_cast<IntType>(f);
|
||||
}
|
||||
|
||||
template <typename... Tx>
|
||||
static IntType bits(T f, Tx... fx) {
|
||||
return bit(f) | bits(fx...);
|
||||
}
|
||||
|
||||
static IntType bits() {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace Common
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2024 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace Common {
|
||||
|
||||
template <class T>
|
||||
class Singleton {
|
||||
public:
|
||||
static T* Instance() {
|
||||
if (!m_instance) {
|
||||
m_instance = std::make_unique<T>();
|
||||
}
|
||||
return m_instance.get();
|
||||
}
|
||||
|
||||
protected:
|
||||
Singleton();
|
||||
~Singleton();
|
||||
|
||||
private:
|
||||
static inline std::unique_ptr<T> m_instance{};
|
||||
};
|
||||
|
||||
} // namespace Common
|
||||
@@ -0,0 +1,39 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2024 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
using s8 = std::int8_t;
|
||||
using s16 = std::int16_t;
|
||||
using s32 = std::int32_t;
|
||||
using s64 = std::int64_t;
|
||||
|
||||
using u8 = std::uint8_t;
|
||||
using u16 = std::uint16_t;
|
||||
using u32 = std::uint32_t;
|
||||
using u64 = std::uint64_t;
|
||||
|
||||
using f32 = float;
|
||||
using f64 = double;
|
||||
|
||||
using u128 = std::array<std::uint64_t, 2>;
|
||||
static_assert(sizeof(u128) == 16, "u128 must be 128 bits wide");
|
||||
|
||||
using VAddr = uintptr_t;
|
||||
using PAddr = uintptr_t;
|
||||
|
||||
#define PS4_SYSV_ABI __attribute__((sysv_abi))
|
||||
|
||||
// UDLs for memory size values
|
||||
constexpr unsigned long long operator""_KB(unsigned long long x) {
|
||||
return 1024ULL * x;
|
||||
}
|
||||
constexpr unsigned long long operator""_MB(unsigned long long x) {
|
||||
return 1024_KB * x;
|
||||
}
|
||||
constexpr unsigned long long operator""_GB(unsigned long long x) {
|
||||
return 1024_MB * x;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// Input side of the BBSC cinematic camera. While it is on, the keyboard and mouse fly the camera
|
||||
// and never reach the game (shadPS4 would otherwise map them to the pad and move the hunter too);
|
||||
// the controller is untouched. Keys:
|
||||
// F1 on/off | hold right mouse: look | WASD move | E / Space up, Q / C down
|
||||
// Shift fast, Alt slow | wheel: speed | Z / X zoom, V reset zoom | , . roll, / reset roll
|
||||
// arrow keys: look | [ ] smoothing (glide) less / more
|
||||
|
||||
#include <mutex>
|
||||
|
||||
#include <SDL3/SDL_events.h>
|
||||
#include <SDL3/SDL_mouse.h>
|
||||
|
||||
#include "common/logging/log.h"
|
||||
#include "core/bbsc_cinematic.h"
|
||||
#include "core/bbsc_persistence.h"
|
||||
|
||||
namespace Core::Bbsc {
|
||||
namespace {
|
||||
|
||||
std::mutex cine_mutex;
|
||||
CinematicInput cine_state;
|
||||
bool looking = false;
|
||||
|
||||
bool CinematicEnabledBySwitch() {
|
||||
static const bool enabled = SwitchEnabled("SHADPS4_BBSC_CINEMATIC");
|
||||
return enabled;
|
||||
}
|
||||
|
||||
void SetKey(SDL_Keycode key, bool down) {
|
||||
switch (key) {
|
||||
case SDLK_W: cine_state.forward = down; break;
|
||||
case SDLK_S: cine_state.back = down; break;
|
||||
case SDLK_A: cine_state.left = down; break;
|
||||
case SDLK_D: cine_state.right = down; break;
|
||||
case SDLK_E:
|
||||
case SDLK_SPACE: cine_state.up = down; break;
|
||||
case SDLK_Q:
|
||||
case SDLK_C: cine_state.down = down; break;
|
||||
case SDLK_LSHIFT:
|
||||
case SDLK_RSHIFT: cine_state.fast = down; break;
|
||||
case SDLK_LALT:
|
||||
case SDLK_RALT: cine_state.slow = down; break;
|
||||
case SDLK_Z: cine_state.zoom_in = down; break;
|
||||
case SDLK_X: cine_state.zoom_out = down; break;
|
||||
case SDLK_V: cine_state.zoom_reset = cine_state.zoom_reset || down; break;
|
||||
case SDLK_COMMA: cine_state.roll_left = down; break;
|
||||
case SDLK_PERIOD: cine_state.roll_right = down; break;
|
||||
case SDLK_SLASH: cine_state.roll_reset = cine_state.roll_reset || down; break;
|
||||
case SDLK_UP: cine_state.look_up = down; break;
|
||||
case SDLK_DOWN: cine_state.look_down = down; break;
|
||||
case SDLK_LEFT: cine_state.look_left = down; break;
|
||||
case SDLK_RIGHT: cine_state.look_right = down; break;
|
||||
case SDLK_LEFTBRACKET:
|
||||
if (down) --cine_state.smooth_steps;
|
||||
break;
|
||||
case SDLK_RIGHTBRACKET:
|
||||
if (down) ++cine_state.smooth_steps;
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
void ReleaseAll(SDL_Window* window) {
|
||||
const bool enabled = cine_state.enabled;
|
||||
cine_state = CinematicInput{};
|
||||
cine_state.enabled = enabled;
|
||||
if (looking && window != nullptr) {
|
||||
SDL_SetWindowRelativeMouseMode(window, false);
|
||||
}
|
||||
looking = false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool CinematicHandleEvent(const SDL_Event& event, SDL_Window* window) {
|
||||
if (!CinematicEnabledBySwitch()) {
|
||||
return false;
|
||||
}
|
||||
std::scoped_lock lock{cine_mutex};
|
||||
if (event.type == SDL_EVENT_KEY_DOWN && event.key.key == SDLK_F1) {
|
||||
if (!event.key.repeat) {
|
||||
cine_state.enabled = !cine_state.enabled;
|
||||
ReleaseAll(window);
|
||||
LOG_INFO(Debug, "BBSC cinematic camera: {} (F1)", cine_state.enabled ? "on" : "off");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (event.type == SDL_EVENT_KEY_UP && event.key.key == SDLK_F1) {
|
||||
return true;
|
||||
}
|
||||
if (!cine_state.enabled) {
|
||||
return false;
|
||||
}
|
||||
switch (event.type) {
|
||||
case SDL_EVENT_KEY_DOWN:
|
||||
case SDL_EVENT_KEY_UP: {
|
||||
const SDL_Keycode key = event.key.key;
|
||||
if (key >= SDLK_F2 && key <= SDLK_F12) {
|
||||
return false; // the emulator's own hotkeys (fullscreen, fps, pause...) keep working
|
||||
}
|
||||
SetKey(key, event.type == SDL_EVENT_KEY_DOWN);
|
||||
return true;
|
||||
}
|
||||
case SDL_EVENT_MOUSE_BUTTON_DOWN:
|
||||
case SDL_EVENT_MOUSE_BUTTON_UP:
|
||||
if (event.button.button == SDL_BUTTON_RIGHT) {
|
||||
looking = event.type == SDL_EVENT_MOUSE_BUTTON_DOWN;
|
||||
if (window != nullptr) {
|
||||
SDL_SetWindowRelativeMouseMode(window, looking);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
case SDL_EVENT_MOUSE_MOTION:
|
||||
if (looking) {
|
||||
cine_state.mouse_dx += event.motion.xrel;
|
||||
cine_state.mouse_dy += event.motion.yrel;
|
||||
}
|
||||
return true;
|
||||
case SDL_EVENT_MOUSE_WHEEL:
|
||||
cine_state.wheel += event.wheel.y > 0.0f ? 1 : (event.wheel.y < 0.0f ? -1 : 0);
|
||||
return true;
|
||||
case SDL_EVENT_WINDOW_FOCUS_LOST:
|
||||
ReleaseAll(window);
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
CinematicInput TakeCinematicInput() {
|
||||
std::scoped_lock lock{cine_mutex};
|
||||
CinematicInput out = cine_state;
|
||||
cine_state.mouse_dx = 0.0f;
|
||||
cine_state.mouse_dy = 0.0f;
|
||||
cine_state.wheel = 0;
|
||||
cine_state.smooth_steps = 0;
|
||||
cine_state.zoom_reset = false;
|
||||
cine_state.roll_reset = false;
|
||||
return out;
|
||||
}
|
||||
|
||||
void CinematicForceOff() {
|
||||
std::scoped_lock lock{cine_mutex};
|
||||
cine_state = CinematicInput{};
|
||||
}
|
||||
|
||||
void CinematicForceOn() {
|
||||
std::scoped_lock lock{cine_mutex};
|
||||
if (!cine_state.enabled) {
|
||||
cine_state = CinematicInput{};
|
||||
cine_state.enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace Core::Bbsc
|
||||
@@ -0,0 +1,45 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
// BBSC cinematic camera (2026-09-28): a free camera for filming, flown with keyboard and mouse while
|
||||
// the controller keeps playing the hunter. The window side (sdl_window.cpp) feeds it input; the
|
||||
// game side (bloodborne_re.cpp, BbscCinematicCameraHook) turns the game's own debug free camera on
|
||||
// and moves it. Switch SHADPS4_BBSC_CINEMATIC (default on). F1 toggles it.
|
||||
|
||||
union SDL_Event;
|
||||
struct SDL_Window;
|
||||
|
||||
namespace Core::Bbsc {
|
||||
|
||||
// Window thread. True when the event belongs to the cinematic camera and must not reach the game.
|
||||
bool CinematicHandleEvent(const SDL_Event& event, SDL_Window* window);
|
||||
|
||||
struct CinematicInput {
|
||||
bool enabled = false;
|
||||
bool forward = false, back = false, left = false, right = false, up = false, down = false;
|
||||
bool fast = false, slow = false;
|
||||
bool zoom_in = false, zoom_out = false, zoom_reset = false;
|
||||
bool roll_left = false, roll_right = false, roll_reset = false;
|
||||
bool look_up = false, look_down = false, look_left = false, look_right = false;
|
||||
float mouse_dx = 0.0f; // accumulated since the last TakeCinematicInput
|
||||
float mouse_dy = 0.0f;
|
||||
int wheel = 0; // speed steps, accumulated
|
||||
int smooth_steps = 0; // smoothing steps, accumulated
|
||||
// Controller (mod only, -1..1, added to the keys): left stick move, right stick look, L1/R1
|
||||
// down/up, L2/R2 zoom out/in.
|
||||
float pad_move_x = 0.0f, pad_move_y = 0.0f, pad_rise = 0.0f;
|
||||
float pad_look_x = 0.0f, pad_look_y = 0.0f, pad_zoom = 0.0f;
|
||||
};
|
||||
|
||||
// Game thread: the current keys plus everything accumulated since the last call.
|
||||
CinematicInput TakeCinematicInput();
|
||||
|
||||
// Game thread: the camera could not be switched on (logged); the toggle goes back to off.
|
||||
void CinematicForceOff();
|
||||
|
||||
// Game thread: switches the camera on (you died in co-op); nothing when it is already on.
|
||||
void CinematicForceOn();
|
||||
|
||||
} // namespace Core::Bbsc
|
||||
@@ -0,0 +1,112 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// bbport: BBSC's own texts in the game's language, see bbsc_i18n.h.
|
||||
|
||||
#include "core/bbsc_i18n.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <mutex>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace Core::Bbsc {
|
||||
namespace {
|
||||
|
||||
// The languages of bbsc/translations, in the column order of bbsc_translations.inc.
|
||||
enum Language { Spanish, French, German, Italian, Portuguese, Russian, LanguageCount };
|
||||
|
||||
struct Translation {
|
||||
const char16_t* english;
|
||||
const char16_t* text[LanguageCount];
|
||||
};
|
||||
|
||||
constexpr Translation Translations[] = {
|
||||
#include "core/bbsc_translations.inc"
|
||||
};
|
||||
|
||||
constexpr std::u16string_view BossTag = u"<?npcNameId@";
|
||||
constexpr std::u16string_view BossPlaceholder = u"{boss}";
|
||||
|
||||
// The PS4 system language codes of the game (BB_LANGUAGE): -1 for English and the others.
|
||||
int GameLanguage() {
|
||||
static const int language = [] {
|
||||
const char* value = std::getenv("BB_LANGUAGE");
|
||||
switch (value != nullptr ? std::atoi(value) : 1) {
|
||||
case 3: // Spanish (Spain)
|
||||
case 20: // Spanish (Latin America)
|
||||
return int{Spanish};
|
||||
case 2: // French
|
||||
case 22: // French (Canada)
|
||||
return int{French};
|
||||
case 4:
|
||||
return int{German};
|
||||
case 5:
|
||||
return int{Italian};
|
||||
case 7: // Portuguese (Portugal)
|
||||
case 17: // Portuguese (Brazil)
|
||||
return int{Portuguese};
|
||||
case 8:
|
||||
return int{Russian};
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
}();
|
||||
return language;
|
||||
}
|
||||
|
||||
const char16_t* Lookup(std::u16string_view english, int language) {
|
||||
for (const Translation& entry : Translations) {
|
||||
if (english == entry.english) {
|
||||
return entry.text[language];
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::u16string Replace(std::u16string text, std::u16string_view from, std::u16string_view to) {
|
||||
for (size_t at = text.find(from); at != std::u16string::npos; at = text.find(from, at)) {
|
||||
text.replace(at, from.size(), to);
|
||||
at += to.size();
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const char16_t* Text(const char16_t* english) {
|
||||
const int language = GameLanguage();
|
||||
if (english == nullptr || language < 0) {
|
||||
return english;
|
||||
}
|
||||
if (const char16_t* text = Lookup(english, language)) {
|
||||
return text;
|
||||
}
|
||||
// A text naming one boss: its {boss} form's translation, with the same tag. Made once per
|
||||
// text and kept (the game keeps pointing at it).
|
||||
const std::u16string_view view{english};
|
||||
const size_t tag = view.find(BossTag);
|
||||
const size_t tag_end = tag != std::u16string_view::npos ? view.find(u"?>", tag) : tag;
|
||||
if (tag_end == std::u16string_view::npos) {
|
||||
return english;
|
||||
}
|
||||
static std::mutex mutex;
|
||||
static std::unordered_map<std::u16string_view, std::u16string> made;
|
||||
std::scoped_lock lock{mutex};
|
||||
if (const auto found = made.find(view); found != made.end()) {
|
||||
return found->second.c_str();
|
||||
}
|
||||
const std::u16string_view boss = view.substr(tag, tag_end + 2 - tag);
|
||||
std::u16string form{view};
|
||||
form.replace(tag, boss.size(), BossPlaceholder);
|
||||
const char16_t* text = Lookup(form, language);
|
||||
if (text == nullptr) {
|
||||
return english;
|
||||
}
|
||||
return made.emplace(view, Replace(text, BossPlaceholder, boss)).first->second.c_str();
|
||||
}
|
||||
|
||||
std::u16string TextWith(const char16_t* english_template, std::u16string_view boss) {
|
||||
const int language = GameLanguage();
|
||||
const char16_t* text = language >= 0 ? Lookup(english_template, language) : nullptr;
|
||||
return Replace(text != nullptr ? text : english_template, BossPlaceholder, boss);
|
||||
}
|
||||
|
||||
} // namespace Core::Bbsc
|
||||
@@ -0,0 +1,21 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// bbport: BBSC's own texts in the game's language. BBSC writes them in English; the translations
|
||||
// (bbsc/translations/*.json, made into bbsc_translations.inc by gen_translations.py) are looked up
|
||||
// by that English text, so BBSC's sources keep their English strings as they are.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace Core::Bbsc {
|
||||
|
||||
// `english` in the game's language (BB_LANGUAGE, the PS4 system language code), or `english`
|
||||
// itself when there is no translation. A text naming one boss (<?npcNameId@N?>) uses the
|
||||
// translation of its {boss} form. The pointer stays valid for the whole run.
|
||||
const char16_t* Text(const char16_t* english);
|
||||
|
||||
// The translation of `english_template`, whose {boss} is replaced by `boss`.
|
||||
std::u16string TextWith(const char16_t* english_template, std::u16string_view boss);
|
||||
|
||||
} // namespace Core::Bbsc
|
||||
@@ -0,0 +1,142 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include "core/bbsc_memscan.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#else
|
||||
// bbport: the Linux search.
|
||||
#include <cinttypes>
|
||||
#include <cstdio>
|
||||
#include <sys/uio.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
namespace Core::Bbsc {
|
||||
|
||||
namespace {
|
||||
|
||||
#ifdef _WIN32
|
||||
// One region. The emulator changes page protection all the time (GPU tracking), so a page that was
|
||||
// readable a moment ago may fault now: the search runs under SEH and a fault just ends the region.
|
||||
void SearchRegion(const std::uint8_t* start, std::size_t size, const std::uint8_t* needle,
|
||||
std::size_t length, std::vector<std::uint64_t>& hits, std::size_t max_hits) {
|
||||
__try {
|
||||
const std::uint8_t* end = start + size;
|
||||
const std::uint8_t* at = start;
|
||||
while (hits.size() < max_hits && end - at >= static_cast<std::ptrdiff_t>(length)) {
|
||||
at = static_cast<const std::uint8_t*>(
|
||||
std::memchr(at, needle[0], static_cast<std::size_t>(end - at) - length + 1));
|
||||
if (at == nullptr) {
|
||||
break;
|
||||
}
|
||||
if (std::memcmp(at, needle, length) == 0) {
|
||||
hits.push_back(reinterpret_cast<std::uint64_t>(at));
|
||||
}
|
||||
++at;
|
||||
}
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
}
|
||||
}
|
||||
#else
|
||||
// bbport: one region, copied in slices through process_vm_readv on this process: a page the GPU
|
||||
// side just protected, or a region unmapped meanwhile, only fails that read (as the SEH guard on
|
||||
// Windows) instead of faulting. Consecutive slices overlap by the needle's length.
|
||||
void SearchRegion(std::uint64_t start, std::uint64_t end, const std::uint8_t* needle,
|
||||
std::size_t length, std::vector<std::uint8_t>& buffer,
|
||||
std::vector<std::uint64_t>& hits, std::size_t max_hits) {
|
||||
const pid_t self = getpid();
|
||||
std::uint64_t at = start;
|
||||
while (at < end && hits.size() < max_hits) {
|
||||
const auto want =
|
||||
static_cast<std::size_t>(std::min<std::uint64_t>(buffer.size(), end - at));
|
||||
iovec local{buffer.data(), want};
|
||||
iovec remote{reinterpret_cast<void*>(at), want};
|
||||
const ssize_t got = process_vm_readv(self, &local, 1, &remote, 1, 0);
|
||||
if (got <= 0) {
|
||||
at = (at & ~std::uint64_t{0xFFF}) + 0x1000; // an unreadable page: the next one
|
||||
continue;
|
||||
}
|
||||
const auto size = static_cast<std::size_t>(got);
|
||||
const std::uint8_t* data = buffer.data();
|
||||
const std::uint8_t* scan = data;
|
||||
while (size >= length && hits.size() < max_hits) {
|
||||
if (scan > data + size - length) {
|
||||
break;
|
||||
}
|
||||
const auto left = static_cast<std::size_t>(data + size - length + 1 - scan);
|
||||
scan = static_cast<const std::uint8_t*>(std::memchr(scan, needle[0], left));
|
||||
if (scan == nullptr) {
|
||||
break;
|
||||
}
|
||||
if (std::memcmp(scan, needle, length) == 0) {
|
||||
hits.push_back(at + static_cast<std::uint64_t>(scan - data));
|
||||
}
|
||||
++scan;
|
||||
}
|
||||
at += at + size >= end || size < length ? size : size - length + 1;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<std::uint64_t> FindInMemory(std::span<const std::uint8_t> needle, std::uint64_t lo,
|
||||
std::uint64_t hi, std::size_t max_hits) {
|
||||
std::vector<std::uint64_t> hits;
|
||||
#ifdef _WIN32
|
||||
if (needle.empty()) {
|
||||
return hits;
|
||||
}
|
||||
constexpr DWORD Readable = PAGE_READONLY | PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READ |
|
||||
PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY;
|
||||
std::uint64_t at = lo;
|
||||
while (at < hi && hits.size() < max_hits) {
|
||||
MEMORY_BASIC_INFORMATION info{};
|
||||
if (VirtualQuery(reinterpret_cast<void*>(at), &info, sizeof(info)) == 0) {
|
||||
break;
|
||||
}
|
||||
const auto start = reinterpret_cast<std::uint64_t>(info.BaseAddress);
|
||||
const std::uint64_t end = start + info.RegionSize;
|
||||
if (info.State == MEM_COMMIT && (info.Protect & Readable) != 0 &&
|
||||
(info.Protect & (PAGE_GUARD | PAGE_NOACCESS)) == 0) {
|
||||
SearchRegion(reinterpret_cast<const std::uint8_t*>(start), info.RegionSize,
|
||||
needle.data(), needle.size(), hits, max_hits);
|
||||
}
|
||||
if (end <= at) {
|
||||
break;
|
||||
}
|
||||
at = end;
|
||||
}
|
||||
#else
|
||||
if (needle.empty()) {
|
||||
return hits;
|
||||
}
|
||||
// The readable mappings in [lo, hi), from /proc/self/maps.
|
||||
FILE* maps = std::fopen("/proc/self/maps", "r");
|
||||
if (maps == nullptr) {
|
||||
return hits;
|
||||
}
|
||||
std::vector<std::uint8_t> buffer(std::size_t{1} << 20);
|
||||
char line[512];
|
||||
while (hits.size() < max_hits && std::fgets(line, sizeof(line), maps) != nullptr) {
|
||||
std::uint64_t start = 0;
|
||||
std::uint64_t end = 0;
|
||||
char prot[5] = {};
|
||||
if (std::sscanf(line, "%" SCNx64 "-%" SCNx64 " %4s", &start, &end, prot) != 3 ||
|
||||
prot[0] != 'r' || end <= lo || start >= hi) {
|
||||
continue;
|
||||
}
|
||||
SearchRegion(std::max(start, lo), std::min(end, hi), needle.data(), needle.size(), buffer,
|
||||
hits, max_hits);
|
||||
}
|
||||
std::fclose(maps);
|
||||
#endif
|
||||
return hits;
|
||||
}
|
||||
|
||||
} // namespace Core::Bbsc
|
||||
@@ -0,0 +1,21 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// BBSC: find bytes in the game's memory (the param tables are found by their header text). Kept
|
||||
// out of bloodborne_re.cpp so <windows.h> stays out of it. Shared by the emulator and the mod DLL.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
namespace Core::Bbsc {
|
||||
|
||||
// Every address in [lo, hi) where `needle` sits in committed, readable memory (at most max_hits).
|
||||
// Slow (a full walk of the address range): call it once, off the game thread.
|
||||
std::vector<std::uint64_t> FindInMemory(std::span<const std::uint8_t> needle, std::uint64_t lo,
|
||||
std::uint64_t hi, std::size_t max_hits);
|
||||
|
||||
} // namespace Core::Bbsc
|
||||
@@ -0,0 +1,212 @@
|
||||
// Generated by tools/gamedata/gen_messages.py from the game's English text - do not edit.
|
||||
// {category, message id, BBSC text} // original text
|
||||
{30, 102001, u"<?joinName?> joined your game."}, // The bell's resonance has beckoned <?joinName?>.
|
||||
{30, 102003, u"<?joinName?> joined your game."}, // <?joinName?> has arrived by the bell's resonance
|
||||
{30, 102004, u"<?joinName?> joined your game."}, // <?joinName?> has arrived by the bell's resonance
|
||||
{30, 102005, u"<?joinName?> joined your game."}, // <?joinName?> has arrived by the bell's resonance
|
||||
{30, 102006, u"<?joinName?> joined your game."}, // Confederate <?joinName?> has arrived by the bell's resonance.
|
||||
{30, 102007, u"<?joinName?> joined your game."}, // Confederate <?joinName?> has arrived by the bell's resonance.
|
||||
{30, 102056, u"<?joinName?> joined your game."}, // Confederate <?joinName?> has arrived by the bell's resonance.
|
||||
{30, 102057, u"<?joinName?> joined your game."}, // Confederate <?joinName?> has arrived by the bell's resonance.
|
||||
{30, 102051, u"<?joinName?> joined your game."}, // Old Hunter <?joinName?> has arrived by the bell's resonance.
|
||||
{30, 102052, u"<?joinName?> joined your game."}, // Old Hunter <?joinName?> has arrived by the bell's resonance.
|
||||
{30, 102053, u"<?joinName?> joined your game."}, // Old Hunter <?joinName?> has arrived by the bell's resonance.
|
||||
{30, 102054, u"<?joinName?> joined your game."}, // Old Hunter <?joinName?> has arrived by the bell's resonance.
|
||||
{30, 102055, u"<?joinName?> joined your game."}, // Old Hunter <?joinName?> has arrived by the bell's resonance.
|
||||
{30, 102100, u"You joined <?hostName?>'s game."}, // Entered the world of <?hostName?>, who beckoned you here.
|
||||
{30, 102200, u"You joined <?hostName?>'s game."}, // Entered the world of <?hostName?>.
|
||||
{30, 102600, u"You joined <?hostName?>'s game."}, // You are now in the world of the beckoner, <?hostName?>.
|
||||
{30, 102700, u"You joined <?hostName?>'s game."}, // You are now in the world of the beckoner, <?hostName?>.
|
||||
{30, 103100, u"You died."}, // You died. Returning to your world.
|
||||
{30, 103200, u"You died."}, // You died. Returning to your world.
|
||||
{30, 103300, u"You died."}, // You died. Returning to your world.
|
||||
{30, 103400, u"You died."}, // You died. Returning to your world.
|
||||
{30, 103500, u"You died."}, // You died. Returning to your world.
|
||||
{30, 103101, u"The host died.\nYou stay in their game."}, // Your beckoner has died. Returning to own world.
|
||||
{30, 103001, u"<?deadName?> died.\nThey will respawn."}, // Cooperator <?deadName?> has died.
|
||||
{30, 103006, u"<?deadName?> died.\nThey will respawn."}, // Confederate <?deadName?> has died.
|
||||
{30, 103007, u"<?deadName?> died.\nThey will respawn."}, // Confederate <?deadName?> has died.
|
||||
{30, 103056, u"<?deadName?> died.\nThey will respawn."}, // Confederate <?deadName?> has died.
|
||||
{30, 103057, u"<?deadName?> died.\nThey will respawn."}, // Confederate <?deadName?> has died.
|
||||
{30, 103051, u"<?deadName?> died.\nThey will respawn."}, // Old Hunter <?deadName?> has died.
|
||||
{30, 103052, u"<?deadName?> died.\nThey will respawn."}, // Old Hunter <?deadName?> has died.
|
||||
{30, 103053, u"<?deadName?> died.\nThey will respawn."}, // Old Hunter <?deadName?> has died.
|
||||
{30, 103054, u"<?deadName?> died.\nThey will respawn."}, // Old Hunter <?deadName?> has died.
|
||||
{30, 103055, u"<?deadName?> died.\nThey will respawn."}, // Old Hunter <?deadName?> has died.
|
||||
{30, 106200, u"The host <?hostName?> died.\nYou stay in their game."}, // Beckoner <?hostName?> has fallen. It is time to bid farewell.
|
||||
{30, 105200, u"The host started the battle against\n<?npcNameId@1000?>."}, // Battle with <?npcNameId@1000?> has commenced. Returning to your world.
|
||||
{30, 105300, u"The host started the battle against\n<?npcNameId@1000?>."}, // Battle with <?npcNameId@1000?> has commenced. Returning to your world.
|
||||
{30, 105400, u"The host started the battle against\n<?npcNameId@1000?>."}, // Battle with <?npcNameId@1000?> has commenced. Returning to your world.
|
||||
{30, 105500, u"The host started the battle against\n<?npcNameId@1000?>."}, // Battle with <?npcNameId@1000?> has commenced. Returning to your world.
|
||||
{30, 141010, u"The host started the battle against\n<?npcNameId@1000?>."}, // Battle with <?npcNameId@1000?> has commenced. Returning to your world.
|
||||
{30, 141020, u"The host started the battle against\n<?npcNameId@106000?>."}, // Battle with <?npcNameId@106000?> has commenced. Returning to your world.
|
||||
{30, 141030, u"The host started the battle against\n<?npcNameId@127000?>."}, // Battle with <?npcNameId@127000?> has commenced. Returning to your world.
|
||||
{30, 141040, u"The host started the battle against\n<?npcNameId@209000?>."}, // Battle with <?npcNameId@209000?> has commenced. Returning to your world.
|
||||
{30, 141050, u"The host started the battle against\n<?npcNameId@210000?>."}, // Battle with <?npcNameId@210000?> has commenced. Returning to your world.
|
||||
{30, 141060, u"The host started the battle against\n<?npcNameId@212010?>."}, // Battle with <?npcNameId@212010?> has commenced. Returning to your world.
|
||||
{30, 141070, u"The host started the battle against\n<?npcNameId@212020?>."}, // Battle with <?npcNameId@212020?> has commenced. Returning to your world.
|
||||
{30, 141080, u"The host started the battle against\n<?npcNameId@212030?>."}, // Battle with <?npcNameId@212030?> has commenced. Returning to your world.
|
||||
{30, 141090, u"The host started the battle against\n<?npcNameId@216000?>."}, // Battle with <?npcNameId@216000?> has commenced. Returning to your world.
|
||||
{30, 141100, u"The host started the battle against\n<?npcNameId@232000?>."}, // Battle with <?npcNameId@232000?> has commenced. Returning to your world.
|
||||
{30, 141110, u"The host started the battle against\n<?npcNameId@251000?>."}, // Battle with <?npcNameId@251000?> has commenced. Returning to your world.
|
||||
{30, 141120, u"The host started the battle against\n<?npcNameId@257000?>."}, // Battle with <?npcNameId@257000?> has commenced. Returning to your world.
|
||||
{30, 141130, u"The host started the battle against\n<?npcNameId@271000?>."}, // Battle with <?npcNameId@271000?> has commenced. Returning to your world.
|
||||
{30, 141140, u"The host started the battle against\n<?npcNameId@1000?>."}, // Battle with <?npcNameId@1000?> has commenced. Returning to your world.
|
||||
{30, 141150, u"The host started the battle against\n<?npcNameId@272000?>."}, // Battle with <?npcNameId@272000?> has commenced. Returning to your world.
|
||||
{30, 141160, u"The host started the battle against\n<?npcNameId@305000?>."}, // Battle with <?npcNameId@305000?> has commenced. Returning to your world.
|
||||
{30, 141170, u"The host started the battle against\n<?npcNameId@306000?>."}, // Battle with <?npcNameId@306000?> has commenced. Returning to your world.
|
||||
{30, 141180, u"The host started the battle against\n<?npcNameId@313000?>."}, // Battle with <?npcNameId@313000?> has commenced. Returning to your world.
|
||||
{30, 141190, u"The host started the battle against\n<?npcNameId@500000?>."}, // Battle with <?npcNameId@500000?> has commenced. Returning to your world.
|
||||
{30, 141200, u"The host started the battle against\n<?npcNameId@501000?>."}, // Battle with <?npcNameId@501000?> has commenced. Returning to your world.
|
||||
{30, 141210, u"The host started the battle against\n<?npcNameId@502000?>."}, // Battle with <?npcNameId@502000?> has commenced. Returning to your world.
|
||||
{30, 141220, u"The host started the battle against\n<?npcNameId@503000?>."}, // Battle with <?npcNameId@503000?> has commenced. Returning to your world.
|
||||
{30, 141230, u"The host started the battle against\n<?npcNameId@504000?>."}, // Battle with <?npcNameId@504000?> has commenced. Returning to your world.
|
||||
{30, 141240, u"The host started the battle against\n<?npcNameId@505000?>."}, // Battle with <?npcNameId@505000?> has commenced. Returning to your world.
|
||||
{30, 141250, u"The host started the battle against\n<?npcNameId@506000?>."}, // Battle with <?npcNameId@506000?> has commenced. Returning to your world.
|
||||
{30, 141260, u"The host started the battle against\n<?npcNameId@507000?>."}, // Battle with <?npcNameId@507000?> has commenced. Returning to your world.
|
||||
{30, 141270, u"The host started the battle against\n<?npcNameId@508000?>."}, // Battle with <?npcNameId@508000?> has commenced. Returning to your world.
|
||||
{30, 141280, u"The host started the battle against\n<?npcNameId@509000?>."}, // Battle with <?npcNameId@509000?> has commenced. Returning to your world.
|
||||
{30, 141290, u"The host started the battle against\n<?npcNameId@510000?>."}, // Battle with <?npcNameId@510000?> has commenced. Returning to your world.
|
||||
{30, 141300, u"The host started the battle against\n<?npcNameId@511000?>."}, // Battle with <?npcNameId@511000?> has commenced. Returning to your world.
|
||||
{30, 141310, u"The host started the battle against\n<?npcNameId@512000?>."}, // Battle with <?npcNameId@512000?> has commenced. Returning to your world.
|
||||
{30, 141320, u"The host started the battle against\n<?npcNameId@513000?>."}, // Battle with <?npcNameId@513000?> has commenced. Returning to your world.
|
||||
{30, 141330, u"The host started the battle against\n<?npcNameId@540000?>."}, // Battle with <?npcNameId@540000?> has commenced. Returning to your world.
|
||||
{30, 141340, u"The host started the battle against\n<?npcNameId@551000?>."}, // Battle with <?npcNameId@551000?> has commenced. Returning to your world.
|
||||
{30, 141350, u"The host started the battle against\n<?npcNameId@750000?>."}, // Battle with <?npcNameId@750000?> has commenced. Returning to your world.
|
||||
{30, 141360, u"The host started the battle against\n<?npcNameId@804000?>."}, // Battle with <?npcNameId@804000?> has commenced. Returning to your world.
|
||||
{30, 141361, u"The host started the battle against\n<?npcNameId@805000?>."}, // Battle with <?npcNameId@805000?> has commenced. Returning to your world.
|
||||
{30, 141380, u"The host started the battle against\n<?npcNameId@899000?>."}, // Battle with <?npcNameId@899000?> has commenced. Returning to your world.
|
||||
{30, 105100, u"The host started the battle against\n<?npcNameId@500000?>."}, // Beckoner has challenged <?npcNameId@500000?>.
|
||||
{30, 105199, u"The host started a boss battle."}, // The beckoner has begun the final battle.
|
||||
{30, 105299, u"The host started a boss battle."}, // The beckoner has begun the final battle. Returning to own world.
|
||||
{30, 105399, u"The host started a boss battle."}, // The Executioner has begun the final battle. Returning to own world.
|
||||
{30, 105499, u"The host started a boss battle."}, // The Vileblood has begun the final battle. Returning to own world.
|
||||
{30, 105599, u"The host started a boss battle."}, // The blood-addled hunter has begun the final battle. Returning to own world.
|
||||
{30, 106100, u"<?npcNameId@1000?> defeated."}, // <?npcNameId@1000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142010, u"<?npcNameId@1010?> defeated."}, // <?npcNameId@1010?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142020, u"<?npcNameId@106000?> defeated."}, // <?npcNameId@106000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142030, u"<?npcNameId@127000?> defeated."}, // <?npcNameId@127000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142040, u"<?npcNameId@209000?> defeated."}, // <?npcNameId@209000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142050, u"<?npcNameId@210000?> defeated."}, // <?npcNameId@210000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142060, u"<?npcNameId@212010?> defeated."}, // <?npcNameId@212010?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142070, u"<?npcNameId@212020?> defeated."}, // <?npcNameId@212020?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142080, u"<?npcNameId@212030?> defeated."}, // <?npcNameId@212030?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142090, u"<?npcNameId@216000?> defeated."}, // <?npcNameId@216000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142100, u"<?npcNameId@232000?> defeated."}, // <?npcNameId@232000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142110, u"<?npcNameId@251000?> defeated."}, // <?npcNameId@251000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142120, u"<?npcNameId@257000?> defeated."}, // <?npcNameId@257000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142130, u"<?npcNameId@271000?> defeated."}, // <?npcNameId@271000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142140, u"<?npcNameId@272000?> defeated."}, // <?npcNameId@272000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142150, u"<?npcNameId@304000?> defeated."}, // <?npcNameId@304000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142160, u"<?npcNameId@305000?> defeated."}, // <?npcNameId@305000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142170, u"<?npcNameId@306000?> defeated."}, // <?npcNameId@306000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142180, u"<?npcNameId@313000?> defeated."}, // <?npcNameId@313000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142190, u"<?npcNameId@500000?> defeated."}, // <?npcNameId@500000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142200, u"<?npcNameId@501000?> defeated."}, // <?npcNameId@501000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142210, u"<?npcNameId@502000?> defeated."}, // <?npcNameId@502000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142220, u"<?npcNameId@503000?> defeated."}, // <?npcNameId@503000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142230, u"<?npcNameId@504000?> defeated."}, // <?npcNameId@504000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142240, u"<?npcNameId@505000?> defeated."}, // <?npcNameId@505000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142250, u"<?npcNameId@506000?> defeated."}, // <?npcNameId@506000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142260, u"<?npcNameId@507000?> defeated."}, // <?npcNameId@507000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142270, u"<?npcNameId@508000?> defeated."}, // <?npcNameId@508000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142280, u"<?npcNameId@509000?> defeated."}, // <?npcNameId@509000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142290, u"<?npcNameId@510000?> defeated."}, // <?npcNameId@510000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142300, u"<?npcNameId@511000?> defeated."}, // <?npcNameId@511000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142310, u"<?npcNameId@512000?> defeated."}, // <?npcNameId@512000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142320, u"<?npcNameId@513000?> defeated."}, // <?npcNameId@513000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142330, u"<?npcNameId@540000?> defeated."}, // <?npcNameId@540000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142340, u"<?npcNameId@551000?> defeated."}, // <?npcNameId@551000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142350, u"<?npcNameId@750000?> defeated."}, // <?npcNameId@750000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142360, u"<?npcNameId@804000?> defeated."}, // <?npcNameId@804000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142370, u"<?npcNameId@805000?> defeated."}, // <?npcNameId@805000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 142380, u"<?npcNameId@899000?> defeated."}, // <?npcNameId@899000?> was defeated, ending the hunt. Now, say farewell.
|
||||
{30, 106199, u"Boss defeated."}, // The final battle has ended. It is time to bid farewell.
|
||||
{30, 102002, u"Invader <?joinName?> entered your world!"}, // Adversary <?joinName?> has arrived.
|
||||
{30, 109000, u"A bell-ringing woman appeared.\nAn invader may come."}, // Drawn by the bell's timbre, a bell-ringing woman has appeared somewhere.
|
||||
{30, 103002, u"Invader <?deadName?> was killed."}, // Adversary <?deadName?> has died.
|
||||
{30, 103003, u"Invader <?deadName?> was killed."}, // Vileblood <?deadName?> has died.
|
||||
{30, 103004, u"Invader <?deadName?> was killed."}, // Executioner <?deadName?> has died.
|
||||
{30, 103005, u"Invader <?deadName?> was killed."}, // Hunter of Hunters, <?deadName?>, has died.
|
||||
{30, 106300, u"The host <?hostName?> died.\nYou stay in their game."}, // Executioner <?hostName?> has fallen. It is time to bid farewell.
|
||||
{30, 106400, u"The host <?hostName?> died.\nYou stay in their game."}, // Vileblood <?hostName?> has fallen. It is time to bid farewell.
|
||||
{30, 106500, u"The host <?hostName?> died.\nYou stay in their game."}, // Blood-addled hunter <?hostName?> has fallen. It is time to bid farewell.
|
||||
{30, 20000407, u"Invader <?leaveName?> left\nand went back to their world."}, // Adversary <?leaveName?> returned to their own world.
|
||||
{30, 20000408, u"Invader <?leaveName?> left\nand went back to their world."}, // Adversary <?leaveName?> returned to their own world.
|
||||
{30, 20000409, u"Invader <?leaveName?> left\nand went back to their world."}, // Vileblood <?leaveName?> returned to their own world.
|
||||
{30, 20000410, u"Invader <?leaveName?> left\nand went back to their world."}, // Vileblood <?leaveName?> returned to their own world.
|
||||
{30, 20000411, u"Invader <?leaveName?> left\nand went back to their world."}, // Executioner <?leaveName?> returned to their own world.
|
||||
{30, 20000412, u"Invader <?leaveName?> left\nand went back to their world."}, // Executioner <?leaveName?> returned to their own world.
|
||||
{30, 20000413, u"Invader <?leaveName?> left\nand went back to their world."}, // Hunter of Hunters <?leaveName?> returned to their own world.
|
||||
{30, 20000414, u"Invader <?leaveName?> left\nand went back to their world."}, // Hunter of Hunters <?leaveName?> returned to their own world.
|
||||
{30, 102300, u"You invaded <?hostName?>'s world."}, // You are now in the world of the beckoner, Executioner <?hostName?>
|
||||
{30, 102400, u"You invaded <?hostName?>'s world."}, // You are now in the world of the beckoner, Vileblood <?hostName?>
|
||||
{30, 102500, u"You invaded <?hostName?>'s world."}, // You are now in the world of the beckoner, the blood-addled hunter <?hostName?>.
|
||||
{30, 101001, u"A player answered your bell.\nJoining..."}, // Discovered the chime of a Small Resonant Bell.
|
||||
{30, 101003, u"A player answered your bell.\nJoining..."}, // Discovered the chime of a Small Resonant Bell.
|
||||
{30, 101004, u"A player answered your bell.\nJoining..."}, // Discovered the chime of a Small Resonant Bell.
|
||||
{30, 101005, u"A player answered your bell.\nJoining..."}, // Discovered the chime of a Small Resonant Bell.
|
||||
{30, 101100, u"Found a host.\nJoining their game..."}, // Discovered the chime of a Beckoning Bell.
|
||||
{30, 14000, u"Ring the Hunt Bell\nto call other players?"}, // Expend Insight and seek the assistance of a hunter from another world?
|
||||
{30, 14003, u"Stop the Hunt Bell"}, // Silence the Beckoning Bell
|
||||
{30, 14004, u"Stop the Kin Bell"}, // Silence the Small Resonant Bell
|
||||
{30, 14005, u"Stop the Bloodlust Bell"}, // Silence the Sinister Resonant Bell
|
||||
{30, 100001, u"Hunt Bell rung.\nWaiting for players to join..."}, // Searching for the chime of a Small Resonant Bell...
|
||||
{30, 100100, u"Kin Bell rung.\nLooking for a host to join..."}, // Searching for the chime of a Beckoning Bell...
|
||||
{30, 100200, u"Bloodlust Bell rung.\nLooking for a world to invade..."}, // Searching for the chime of a Sinister Bell...
|
||||
{30, 100300, u"Nobody found.\nRing the bell again to keep looking."}, // Could not discover the chime of a Small Resonant Bell. No insight was consumed.
|
||||
{30, 101002, u"Someone is about to invade your world."}, // Sinister Bell resonates with another...
|
||||
{30, 101200, u"Found a world to invade.\nEntering..."}, // Discovered the chime of a Sinister Bell.
|
||||
{30, 100011, u"Hunt Bell rung (password).\nWaiting for players to join..."}, // Searching for the chime of a Small Resonant Bell with matching password.
|
||||
{30, 100110, u"Kin Bell rung (password).\nLooking for a host to join..."}, // Searching for the chime of a Beckoning Bell with matching password.
|
||||
{30, 101300, u"Found a host,\nbut the beckoner is an Executioner, and thus an adversary."}, // Discovered the chime of a Beckoning Bell, but the beckoner is an Executioner, and thus an adversary.
|
||||
{30, 101400, u"Found a host,\nbut the beckoner is a Vileblood, and thus an adversary."}, // Discovered the chime of a Beckoning Bell, but the beckoner is a Vileblood, and thus an adversary.
|
||||
{30, 101500, u"Found a host,\nbut the beckoner is a blood-addled hunter, and thus an adversary."}, // Discovered the chime of a Beckoning Bell, but the beckoner is a blood-addled hunter, and thus an adversary.
|
||||
{30, 10011270, u"Ring the Hunt Bell to call\nother players into your game."}, // Consume Insight and ring the Beckoning Bell to enlist the cooperation of hunters from other worlds.
|
||||
{30, 10011280, u"Use the Parting Blank to end co-op."}, // Silencing Blank ends such forms of cooperation.
|
||||
{200, 266302, u"Bloodlust Bell"}, // Sinister Bell
|
||||
{200, 280140, u"Bloodlust Bell"}, // Sinister Bell
|
||||
{200, 280240, u"Bloodlust Bell"}, // Sinister Bell
|
||||
{200, 302000, u"Parting Blank"}, // Silencing Blank
|
||||
{200, 302100, u"Parting Blank"}, // Silencing Blank
|
||||
{200, 302110, u"Stop the Hunt Bell"}, // Silence Beckoning Bell
|
||||
{200, 302111, u"Stop the Kin Bell"}, // Silence Small Resonant Bell
|
||||
{200, 302112, u"Stop the Bloodlust Bell"}, // Silence Sinister Resonant Bell
|
||||
{200, 302120, u"End co-op"}, // Disrupt Resonance
|
||||
{200, 302123, u"End co-op"}, // Disrupt Resonance
|
||||
{200, 302124, u"End co-op and go home"}, // Disrupt Resonance and Return Home
|
||||
{201, 269002, u"Quick search: find a host and join their game."}, // Quick-search a Beckoning Bell chime to begin co-op.
|
||||
{201, 269003, u"Quick search: find a world to invade."}, // Quick-search a Sinister Bell chime to become an adversary.
|
||||
{201, 302110, u"Stop calling other players."}, // Silence Bell and cease requesting co-op.
|
||||
{201, 302111, u"Stop looking for a host."}, // Silence Bell and stop helping other Hunters.
|
||||
{201, 302112, u"Stop looking for a world to invade."}, // Silence Bell and cease threat.
|
||||
{204, 269001, u"Find a host and join their game?"}, // Quick-search a Beckoning Bell chime and begin co-op?
|
||||
{204, 269002, u"Find a world to invade?"}, // Quick-search a Sinister Bell chime and become an adversary?
|
||||
{204, 302200, u"Kin Bell rung.\nLooking for a host to join..."}, // Searching for the chime of a Beckoning Bell...
|
||||
{204, 302201, u"Bloodlust Bell rung.\nLooking for a world to invade..."}, // Searching for the chime of a Sinister Bell...
|
||||
{204, 302210, u"Kin Bell rung (password).\nLooking for a host to join..."}, // Searching for the chime of a Beckoning Bell with matching password.
|
||||
{2, 2000, u"Need help? Ring the Hunt Bell."}, // Ring the Beckoning Bell if you require assistance.
|
||||
{2, 1121, u"Call other players into your game\nwith the Hunt Bell."}, // Receive assistance from the hunters of other worlds by using the Beckoning Bell.
|
||||
{2, 1122, u"Join another player's game\nwith the Kin Bell."}, // Assist the hunters of other worlds by using the Small Resonant Bell.
|
||||
{30, 15000011, u"You caught up with <?hostName?>."}, // *
|
||||
{30, 15000012, u"<?joinName?> caught up with you."}, // *
|
||||
{30, 15000035, u"You are in the host's Hunter's Dream."}, // *
|
||||
{10, 4016, u"Hunt Bell"}, // (empty)
|
||||
{20, 4016, u"Ring to call other players into your game.\nCosts no Insight."}, // (empty)
|
||||
{24, 4016, u"An old bell carried by the hunters of the\nworkshop, its toll heard across every world.\n\nRing it, and hunters who answer with a\nKin Bell may join your hunt. Together you\nwalk the night, through every lamp,\ndeath and dream, until the hunt is done."}, // (empty)
|
||||
{10, 4121, u"Kin Bell"}, // (empty)
|
||||
{20, 4121, u"Ring to join another players game.\nCosts no Insight."}, // (empty)
|
||||
{24, 4121, u"A small bell of the choir, its chime\nfaint, but true.\n\nHunters who hear a Hunt Bell may answer\nwith it, crossing into another world to\nstand at the side of kin. What is slain\nthere is slain for both."}, // (empty)
|
||||
{10, 11201, u"Bloodlust Bell"}, // (empty)
|
||||
{20, 11201, u"Ring to invade another players world.\nCosts no Insight."}, // (empty)
|
||||
{24, 11201, u"A bell that sounds only for the blood-drunk.\n\nNo maiden need ring for its toll to be\nheard: every hunter of the night is prey,\nalone or in company, wherever they walk.\n\nThe hunt for hunters never ends."}, // (empty)
|
||||
{10, 11251, u"Parting Blank"}, // (empty)
|
||||
{20, 11251, u"Use to end co-op.\nGuests go home; the host sends everyone home."}, // (empty)
|
||||
{24, 11251, u"A blank of silent powder.\n\nIts inaudible burst breaks the resonance\nbinding hunters of different worlds, and\neach returns to their own night."}, // (empty)
|
||||
{20, 200, u"No longer used. Use the Hunt Bell."}, // Use Insight to ring and call to other worlds for help.
|
||||
{20, 205, u"No longer used. Use the Kin Bell."}, // Ring this bell to help hunters in other worlds.
|
||||
{20, 225, u"No longer used. Use the Bloodlust Bell."}, // Ring to become the enemy of a hunter in another world.
|
||||
{20, 111, u"No longer used. Use the Parting Blank."}, // Fire to end ongoing, and prevent further, cooperation.
|
||||
{10, 4313, u"Duel Bell"}, // (empty)
|
||||
{20, 4313, u"Ring in co-op to turn on PvP between players.\nRing again to turn it off."}, // (empty)
|
||||
{24, 4313, u"A cracked bell with a sour, discordant chime.\n\nRing it while hunting together, and the\nresonance binding the hunters turns to\nrivalry: they may strike one another as foes.\n\nRing it again to restore the old bond.\nOnly the beckoner decides; a cooperator\nwho rings it merely asks."}, // (empty)
|
||||
@@ -0,0 +1,493 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// BBSC byte patches for Bloodborne CUSA03173 01.09: the sites, their expected bytes and what they
|
||||
// become. Shared by the emulator build (bbsc_persistence.cpp) and the injected mod DLL
|
||||
// (mod/bbsc_mod.cpp), so both apply exactly the same list. Standard library only.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
#include "core/bbsc_persistence.h"
|
||||
|
||||
namespace Core::Bbsc::PatchTables {
|
||||
|
||||
using u8 = std::uint8_t;
|
||||
using u64 = std::uint64_t;
|
||||
|
||||
// CUSA03173 01.09 addresses (eboot sha256 d65f0b4f...). See docs/SESSION-LIFECYCLE.md.
|
||||
constexpr u64 MultiPlayStopOffset = 0x01ED07A0; // CSMultiPlayMan::Stop
|
||||
constexpr u64 MatchingExistsOffset = 0x01ED00A0; // CSMultiPlayMan "matching controller exists"
|
||||
constexpr u64 RemoteCleanupOffset = 0x015BED10; // WorldChrMan remote-player cleanup
|
||||
constexpr u64 ClearInsertTasksOffset = 0x01E54830; // CSMultiPlayMan: destroy every member task
|
||||
constexpr u64 RemoveMemberOffset = 0x01E54DB0; // CSMultiPlayMan::RemoveMember(id)
|
||||
constexpr u64 RemoveMemberTwinOffset = 0x01E55050; // second member list removal
|
||||
constexpr u64 MarkSlotsLeavingOffset = 0x014EB860; // marks every remote slot leaving
|
||||
constexpr u64 DeathReloadRequestOffset = 0x013CDE30; // StageTransition request at the end of death
|
||||
|
||||
enum class Group : u8 {
|
||||
Stage, // map loads: StageTransition, stage helper, stage-UID policy, stage flow, destructor
|
||||
Revive, // OnReviveMagic_1 reload
|
||||
Boss, // BlockClearAnim / boss-area leave
|
||||
MatchCheck, // OnMatchingCheck (missing remote character) — weakens disconnect detection
|
||||
Lamp, // lamp prompt 4th availability byte (+0x4B) from 0x0191B6E0
|
||||
Tasks, // keep the per-member CSMultiPlayerIns tasks across a kept load (host death)
|
||||
HostDead, // guest ignores the host's death instead of being sent home through a load
|
||||
NoFogWalls, // co-op area fog walls never appear
|
||||
LeaveLimit, // leaving the co-op area does not send a guest home
|
||||
Spectate, // the death steps run in full except for the reload they request at the end
|
||||
Echoes, // a summoned guest keeps the whole blood echo reward, not half of it
|
||||
GuestItems, // a summoned guest sees, picks up and keeps the items of the host's world
|
||||
SolidNpcs, // a guest sees the host world's NPCs as they are, not as grey wandering ghosts
|
||||
Scaling, // vanilla's boss co-op buff (Lua_MultiDoping) off - BBSC enemy scaling instead
|
||||
KitBells, // the BBSC kit's Hunt Bell gets the Beckoning Bell's special checks (Dream use)
|
||||
SendBacklog, // one member that never answers does not end the session for everyone
|
||||
Hunters, // the host keeps looking for co-op signs past vanilla's two guests
|
||||
};
|
||||
|
||||
// Lamp action update 0x012F5C40 hides the lamp prompt unless bytes +0x48..+0x4B are all zero.
|
||||
// +0x49 (multiplayer) is cleared for the host by the shadp2p seamless hook; +0x4B is written
|
||||
// later from `call 0x0191B6E0` (WorldChrMan scan of characters bound to the lamp entity), which
|
||||
// the shadp2p hook runs too early to affect. Retargeting that single call to a native
|
||||
// `xor eax,eax; ret` clears the fourth gate at this call site only.
|
||||
constexpr u64 LampCharacterGateOffset = 0x0191B6E0;
|
||||
constexpr u64 ReturnZeroOffset = 0x01300AA0;
|
||||
|
||||
struct CallRetarget {
|
||||
const char* name;
|
||||
u64 site; // offset of the 5-byte call/jmp rel32
|
||||
u8 opcode; // 0xE8 call or 0xE9 jmp
|
||||
u64 original; // expected current target
|
||||
u64 target; // replacement target
|
||||
Group group;
|
||||
};
|
||||
|
||||
// Every Stop site below is followed by `test al,al; je` and stores a session field only on
|
||||
// success; MatchingExists returns true exactly when a room is active, so callers keep their
|
||||
// native success branch without the session being torn down. The paired cleanup call is
|
||||
// redirected to a return-only function.
|
||||
constexpr std::array<CallRetarget, 27> Retargets{{
|
||||
// SoloPlayDeath_2 (0x01381DE0) and PartyGhostDeath_2 (0x013824C0) are the last step of the
|
||||
// death script. Both end with `0x01332BC0()` (pick the reload destination) and
|
||||
// `0x013CDE30()` (request the StageTransition that rebuilds the world). BBSC used to skip the
|
||||
// whole step, which left the step's own bookkeeping undone: the death UI objects at
|
||||
// global+0x1690/+0x1698/+0x16B0/+0x16B8 were never released, so a SECOND death never reached
|
||||
// the step again and the player stayed dead with only camera control (run 2026-09-19, host
|
||||
// log: death armed, no "death held", room timed out 60 s later). Dropping only the reload
|
||||
// call lets the step finish - including its own blood echo drop - while the world stays up.
|
||||
{"SoloPlayDeath_2.Reload", 0x01381EDE, 0xE8, DeathReloadRequestOffset, PersistNoOpOffset,
|
||||
Group::Spectate},
|
||||
{"PartyGhostDeath_2.Reload", 0x01382663, 0xE8, DeathReloadRequestOffset, PersistNoOpOffset,
|
||||
Group::Spectate},
|
||||
{"Lamp.CharacterGate", 0x012F83BC, 0xE8, LampCharacterGateOffset, ReturnZeroOffset, Group::Lamp},
|
||||
{"StageTransition.Stop", 0x013CDEF6, 0xE8, MultiPlayStopOffset, MatchingExistsOffset, Group::Stage},
|
||||
{"StageTransition.Cleanup", 0x013CDF17, 0xE8, RemoteCleanupOffset, PersistNoOpOffset, Group::Stage},
|
||||
{"StageHelper.Stop", 0x013CDDF5, 0xE8, MultiPlayStopOffset, MatchingExistsOffset, Group::Stage},
|
||||
{"StageHelper.Cleanup", 0x013CDE26, 0xE9, RemoteCleanupOffset, PersistNoOpOffset, Group::Stage},
|
||||
{"StageUidPolicy.Stop", 0x0193C657, 0xE8, MultiPlayStopOffset, MatchingExistsOffset, Group::Stage},
|
||||
{"StageUidPolicy.Cleanup", 0x0193C689, 0xE8, RemoteCleanupOffset, PersistNoOpOffset, Group::Stage},
|
||||
{"StageFlow.Stop", 0x01939876, 0xE8, MultiPlayStopOffset, MatchingExistsOffset, Group::Stage},
|
||||
// Also retargeted by the shadp2p seamless patches; accepted in either state.
|
||||
{"StageTaskDestructor.Stop", 0x019471B1, 0xE8, MultiPlayStopOffset, MatchingExistsOffset, Group::Stage},
|
||||
{"Revive.Stop", 0x01389DDD, 0xE8, MultiPlayStopOffset, MatchingExistsOffset, Group::Revive},
|
||||
{"Revive.Cleanup", 0x01389DFE, 0xE8, RemoteCleanupOffset, PersistNoOpOffset, Group::Revive},
|
||||
{"BossArea.Stop", 0x0138B410, 0xE8, MultiPlayStopOffset, MatchingExistsOffset, Group::Boss},
|
||||
{"BossArea.Cleanup", 0x0138B437, 0xE8, RemoteCleanupOffset, PersistNoOpOffset, Group::Boss},
|
||||
{"MatchingCheck.Stop", 0x013809B7, 0xE8, MultiPlayStopOffset, MatchingExistsOffset, Group::MatchCheck},
|
||||
{"MatchingCheck.Cleanup", 0x013809E8, 0xE9, RemoteCleanupOffset, PersistNoOpOffset, Group::MatchCheck},
|
||||
// Stage task destructor (0x019470F0) destroys every member task when the summon reload phase
|
||||
// is not 3. A host death is exactly that case, so the host loses the task that recreates the
|
||||
// guest's character and can no longer see the guest (observed 2026-09-16: host shape ins=[]).
|
||||
{"StageTaskDestructor.ClearIns", 0x01947207, 0xE8, ClearInsertTasksOffset, PersistNoOpOffset,
|
||||
Group::Tasks},
|
||||
// PlayerIns death handler 0x018C49F0: when the LOCAL player dies and the session state is 3
|
||||
// (host), it calls RemoveMember for every member task, the twin removal for the second list,
|
||||
// and marks every remote slot as leaving. That is the host-death kick (trace 2026-09-16:
|
||||
// RemoveMember x2 from 0x018C4D82 right at the host's death). Only the host branch is patched.
|
||||
{"HostOnDeath.RemoveMember", 0x018C4D7D, 0xE8, RemoveMemberOffset, PersistNoOpOffset,
|
||||
Group::HostDead},
|
||||
{"HostOnDeath.RemoveMemberTwin", 0x018C4DAC, 0xE8, RemoveMemberTwinOffset, PersistNoOpOffset,
|
||||
Group::HostDead},
|
||||
{"HostOnDeath.MarkSlotsLeaving", 0x018C4DBA, 0xE8, MarkSlotsLeavingOffset, PersistNoOpOffset,
|
||||
Group::HostDead},
|
||||
// Guest item pickup, part 6 (2026-10-04, "items cannot be picked up"). Guest log 2026-10-02:
|
||||
// the map's items were there right after every load (list +0x28: 132 items, 119 pickable) and
|
||||
// the whole list was gone 2-3 s after each "reinsert v8: restarted ... FirstSyncWait". The item
|
||||
// manager's network update 0x017D2F30 reads packet 0x2C; one message kind is "drop your items"
|
||||
// with a list selector ([rbp-0x19C] + 1, jump table 0x017D4C2C): -1 = all three lists
|
||||
// (0x017D3498/34A0/34A8), or one list (0x017D4089/409A/40AB). 0x017D59F0 / 5B40 / 5C40 free the
|
||||
// list's items with +0x48 bit 0x80 and then zero the list head, so every map item vanished
|
||||
// while its glow could stay. In vanilla the guest never owns map items, so the host's join sync
|
||||
// clears them; with BBSC the guest makes its own (GuestItems.SpawnOwn), so the clear is dropped.
|
||||
// Nothing else calls these three.
|
||||
{"GuestItems.KeepOnSyncAll1", 0x017D3498, 0xE8, 0x017D59F0, ReturnZeroOffset, Group::GuestItems},
|
||||
{"GuestItems.KeepOnSyncAll2", 0x017D34A0, 0xE8, 0x017D5B40, ReturnZeroOffset, Group::GuestItems},
|
||||
{"GuestItems.KeepOnSyncAll3", 0x017D34A8, 0xE8, 0x017D5C40, ReturnZeroOffset, Group::GuestItems},
|
||||
{"GuestItems.KeepOnSync1", 0x017D4089, 0xE8, 0x017D59F0, ReturnZeroOffset, Group::GuestItems},
|
||||
{"GuestItems.KeepOnSync3", 0x017D409A, 0xE8, 0x017D5C40, ReturnZeroOffset, Group::GuestItems},
|
||||
{"GuestItems.KeepOnSync2", 0x017D40AB, 0xE8, 0x017D5B40, ReturnZeroOffset, Group::GuestItems},
|
||||
}};
|
||||
|
||||
// Functions made to return immediately. Only for Lua-registered C functions whose whole job is a
|
||||
// session-ending action, so skipping all of it leaves nothing half-applied.
|
||||
struct EntryReturn {
|
||||
const char* name;
|
||||
u64 site;
|
||||
std::array<u8, 6> prologue; // expected first bytes
|
||||
Group group;
|
||||
};
|
||||
|
||||
constexpr std::array<EntryReturn, 3> EntryReturns{{
|
||||
// OnLeave_Limit (Lua, 0x0138A4A0): a living guest who leaves the co-op area is sent home
|
||||
// (event state 2, forced warp global+0x1520, home respawn point 0x01332BC0(-1), StageTransition).
|
||||
// With the fog walls gone the guest may walk past their old spot, so the step is skipped.
|
||||
// Skipping it also removes the only way a guest gets sent home, so it is off by default:
|
||||
// with it on the players reported not being able to leave the world (run 2026-09-19).
|
||||
{"OnLeave_Limit", 0x0138A4A0, {0x55, 0x48, 0x89, 0xE5, 0x53, 0x50}, Group::LeaveLimit},
|
||||
// HostDead (Lua, 0x01381870): the first step on the guest when the host dies (returns at once
|
||||
// on the host itself, state 3). It sets event flag 4047 (0x0132E5A0), shows the host-dead
|
||||
// message and sets death-pending [0x0553B130]+0x70 (0x01386960), and schedules HostDead_1 and
|
||||
// EventMenuBrake (menu brake MenuMan+0x2DC). With the guest staying in the world none of that
|
||||
// is wanted; after it ran the guest could not use ladders (run 2026-09-16).
|
||||
{"HostDead", 0x01381870, {0x55, 0x48, 0x89, 0xE5, 0x41, 0x57}, Group::HostDead},
|
||||
// HostDead_1 (Lua, registered at 0x0138DADB): runs on the guest when the host dies. It sets the
|
||||
// forced-warp byte (global+0x1520), selects the home respawn point (0x01332BC0(-1)), starts a
|
||||
// StageTransition (0x013CDE30) and queues the original player type (+0x8C). Skipping it keeps
|
||||
// the guest standing where he is, with no loading screen, while the host respawns.
|
||||
{"HostDead_1", 0x01381A60, {0x55, 0x48, 0x89, 0xE5, 0x53, 0x50}, Group::HostDead},
|
||||
}};
|
||||
|
||||
// Two-byte instruction rewrites, verified against the bytes that must be there.
|
||||
struct ByteEdit {
|
||||
const char* name;
|
||||
u64 site;
|
||||
std::array<u8, 2> original;
|
||||
std::array<u8, 2> replacement;
|
||||
Group group;
|
||||
};
|
||||
|
||||
constexpr std::array<ByteEdit, 17> ByteEdits{{
|
||||
// The lamp prompt in 0x012F5C40 needs all four of +0x48, +0x49, +0x4A and +0x4B to be zero.
|
||||
// +0x49 is the "I am in a multiplayer session" flag, built right here:
|
||||
// CMP dword ptr [RBX + 0x14], 0x1
|
||||
// SETG AL ; > 1 members
|
||||
// TEST ECX, ECX
|
||||
// SETG CL ; SprjSessionManager member vector is non-empty
|
||||
// OR CL, AL <- 0x012F8321, the two bytes we replace
|
||||
// MOV byte ptr [R13 + 0x49], CL
|
||||
// "OR CL,AL" (08 C1) becomes "XOR CL,CL" (30 C9), so the flag stores 0 and the prompt appears
|
||||
// in co-op. Same length, same register, nothing after it moves.
|
||||
//
|
||||
// This replaces what Lamp.CharacterGate was meant to do. That entry neutralises 0x0191B6E0,
|
||||
// but the decompile shows that call only runs when +0x49 is already zero - so in a session it
|
||||
// never executed and the patch did nothing. Reported 2026-09-22: no lamp prompt at all, with
|
||||
// lamp=true in the log.
|
||||
{"Lamp.MultiplayerGate", 0x012F8321, {0x08, 0xC1}, {0x30, 0xC9}, Group::Lamp},
|
||||
|
||||
// The "Travel" prompt itself comes from the lamp's talk script, not from 0x012F5C40. The talk
|
||||
// condition dispatcher 0x0203AD60 answers the script function "is multiplayer in progress"
|
||||
// (case 0x34) with the same expression as +0x49 above:
|
||||
// CMP dword ptr [RBX + 0x14], 0x1 / SETG AL / TEST ECX, ECX / SETG CL
|
||||
// OR CL, AL <- 0x0203C564
|
||||
// MOVZX EAX, CL ; the script's answer
|
||||
// Clearing it makes every talk script on the HOST see a solo game (lamp prompt, and anything
|
||||
// else the scripts gate on co-op). The guest keeps the vanilla answer: Group::Lamp is a role
|
||||
// group that is only switched on while hosting.
|
||||
{"Lamp.TalkMultiplayerCheck", 0x0203C564, {0x08, 0xC1}, {0x30, 0xC9}, Group::Lamp},
|
||||
|
||||
// Guest item pickup, part 2 (part 1 is below in WideEdits). Picking up a map treasure is
|
||||
// 0x017D8570(item manager [0x0553D6E0], &item id). For a treasure (item +0x48 bit 0x80) it asks
|
||||
// the session state first:
|
||||
// CMP dword ptr [RAX + 0x124], 0x6 ; SprjSessionManager: summoned guest
|
||||
// JNZ 0x017D8768 <- 0x017D85FB: not a guest -> take it locally
|
||||
// ... ; a guest only sends packet 0x2C to the host
|
||||
// and later, on that local path:
|
||||
// CMP dword ptr [RAX + 0x124], 0x6
|
||||
// JZ 0x017D88D3 <- 0x017D88C2: a guest skips 0x017D89F0 (give items)
|
||||
// So a guest takes the host's path: the item is marked taken in the guest's game and its lot is
|
||||
// given to the guest. The inventory is saved live (the guest's save writer 0x0156AA50 only swaps
|
||||
// world snapshots), so the guest keeps it; the host's world is not touched and the host can pick
|
||||
// the same item up too. 0F 85 -> 90 E9 keeps the displacement and the target (0x017D8768).
|
||||
{"GuestItems.PickupAsHost", 0x017D85FB, {0x0F, 0x85}, {0x90, 0xE9}, Group::GuestItems},
|
||||
{"GuestItems.GiveToGuest", 0x017D88C2, {0x74, 0x0F}, {0x90, 0x90}, Group::GuestItems},
|
||||
// Guest item pickup, part 3 (2026-09-26, "guest can't see items", NG+ guest that took nothing):
|
||||
// the guest's map items were never created. Spawning an item (0x017D8370, called for the map's
|
||||
// treasures and for drops) does, for a summoned guest,
|
||||
// CMP dword ptr [RAX + 0x124], 0x6
|
||||
// JNZ 0x017D8511 <- 0x017D83D2: host/solo -> create it here (0x017DE870)
|
||||
// and otherwise only sends packet 0x2C type 3 to the host - nothing ever comes back as an item
|
||||
// the guest can see. 0F 85 -> 90 E9: the guest creates its own, from its own map load and its
|
||||
// own pickup flags, so each player sees the items they have not taken (personal loot).
|
||||
{"GuestItems.SpawnOwn", 0x017D83D2, {0x0F, 0x85}, {0x90, 0xE9}, Group::GuestItems},
|
||||
// Part 4: marking a picked-up item taken (its pickup flag +0x28 and count flag +0xB8, in
|
||||
// 0x017D6780) is skipped for a guest outside its own world:
|
||||
// CMP dword ptr [RAX + 0x124], 0x6 / MOV R14, RBX
|
||||
// JZ 0x017D6AE8 <- 0x017D6ADC: guest -> "own world?" -> skip
|
||||
// CMP dword ptr [R15 + 0x98], -1 / JZ 0x017D6AFC (set the flags)
|
||||
// Without the flag a guest's item came back after every load. 74 0A -> EB 1E jumps straight
|
||||
// to 0x017D6AFC. The flag lives in the live table of the host's world only; the guest's save
|
||||
// keeps its own world (see bloodborne_re.cpp, world state).
|
||||
{"GuestItems.MarkTaken", 0x017D6ADC, {0x74, 0x0A}, {0xEB, 0x1E}, Group::GuestItems},
|
||||
// Part 5 (2026-09-29, "items cannot be seen by the guest", still on 5B726D9D): a guest's log
|
||||
// showed 0x017D8370 and 0x017DE870 never run at all, host or guest, so parts 3 and 3b patched
|
||||
// functions map treasures do not go through. Map treasures are placed by 0x017DC300(item man,
|
||||
// entity, MSB event, ...), called per MSB event of type 4 (treasure) at map load, which for a
|
||||
// summoned guest or anyone in another world skips the whole thing (see WideEdits, part 5).
|
||||
// It then hands each treasure to 0x017D4C40 / 0x017D4FB0 / 0x017D5320 by lot kind, and each
|
||||
// of those numbers the new item only when hosting or solo:
|
||||
// CMP dword ptr [RAX + 0x124], 0x6 ; JZ skip <- guest
|
||||
// CMP dword ptr [R15 + 0x98], -1 ; JNZ skip <- in another world
|
||||
// MOV EAX, [RBX + 8] / INC / store: item number = counter << 4
|
||||
// An item left unnumbered cannot be looked up by its pickup, so both jumps become NOPs: the
|
||||
// guest numbers its own items from its own counter, as its game does in its own world.
|
||||
{"GuestItems.NumberKind1", 0x017D4F62, {0x74, 0x26}, {0x90, 0x90}, Group::GuestItems},
|
||||
{"GuestItems.NumberKind1Away", 0x017D4F6F, {0x75, 0x1C}, {0x90, 0x90}, Group::GuestItems},
|
||||
{"GuestItems.NumberKind3", 0x017D52D9, {0x74, 0x26}, {0x90, 0x90}, Group::GuestItems},
|
||||
{"GuestItems.NumberKind3Away", 0x017D52E6, {0x75, 0x1C}, {0x90, 0x90}, Group::GuestItems},
|
||||
{"GuestItems.NumberKind2", 0x017D5649, {0x74, 0x26}, {0x90, 0x90}, Group::GuestItems},
|
||||
{"GuestItems.NumberKind2Away", 0x017D5656, {0x75, 0x1C}, {0x90, 0x90}, Group::GuestItems},
|
||||
// Grey NPCs (2026-09-29, guest, outside the Dream): the NPC constructor 0x018E4870 gives an
|
||||
// NpcParam row with isChangeWanderGhost (+0x145 bit 7) its ghost model (+0x10C) and type 4
|
||||
// whenever the local player is in someone else's world:
|
||||
// CMP dword ptr [RAX + 0x124], 0x6 ; JZ ... / CMP [RBX + 0x98], -1 ; JZ 0x018E4FDC
|
||||
// MOV ECX, [RAX + 0x145] / TEST CL, 0x80
|
||||
// JZ 0x018E4FDC <- 0x018E4FBF: not a ghost NPC -> keep its own look
|
||||
// ... ghost model, R12D = 4
|
||||
// 74 1B -> EB 1B: every NPC keeps its own model and type for a guest, as the host sees it.
|
||||
// Host and solo never reach this test. The Dream-only row edit (Doll, Gehrman) stays as well.
|
||||
{"SolidNpcs.NoWanderGhost", 0x018E4FBF, {0x74, 0x1B}, {0xEB, 0x1B}, Group::SolidNpcs},
|
||||
// Lua_MultiDoping (0x0138BC70): CMP EAX,2 / JNZ (2 guests -> SpEffect 7501) and CMP EAX,1 / JNZ
|
||||
// (1 guest -> 7500). Both JNZ -> JMP: the boss never gets the vanilla buff, the game still
|
||||
// sets its event flag 4070 after it. BBSC's own scaling (bloodborne_re.cpp) replaces it.
|
||||
{"Scaling.NoVanillaTwoGuests", 0x0138BCC2, {0x75, 0x32}, {0xEB, 0x32}, Group::Scaling},
|
||||
{"Scaling.NoVanillaOneGuest", 0x0138BCF9, {0x75, 0x54}, {0xEB, 0x54}, Group::Scaling},
|
||||
// Boss insight for the guest (2026-10-08, testers: "guest only gets insight for discovering the
|
||||
// boss, not for killing it"). The kill reward 0x018C5070, run in every player's game for every
|
||||
// death, gives a boss's insight ("HumanityDrop", boss row via 0x01F1B4C0, then 0x01F1B760 /
|
||||
// 0x01F1B830 / 0x028986B0) only behind
|
||||
// CMP byte ptr [global + 0x1592], 0 ; in our own world?
|
||||
// JZ 0x018C59A6 <- 0x018C52F9 (0F 84 A7 06 00 00): no -> nothing
|
||||
// A guest is never in its own world. EB 04 jumps over the rest of the JZ to the next
|
||||
// instruction (0x018C52FF), so the guest's copy gives the guest's insight too; host and solo
|
||||
// never took the jump anyway.
|
||||
{"Echoes.GuestBossInsight", 0x018C52F9, {0x0F, 0x84}, {0xEB, 0x04}, Group::Echoes},
|
||||
// Five hunters (test 2026-10-08, host with 2 guests: a third player's co-op sign got
|
||||
// 18 empty sign lists in a row, the same player's invader sign was found at once). The host's
|
||||
// sign search 0x01872360 asks the server for co-op signs (type 7) only while one of vanilla's
|
||||
// two co-op slots is free - the slot table 0x0186FE40 fills [rbp-0x9C] / [rbp-0x90] with the
|
||||
// first and second guest:
|
||||
// CMP [rbp-0x9C], 0 ; JZ 0x018723B1 ; first slot free -> search
|
||||
// MOV EAX, [rbp-0x90] ; TEST EAX, EAX
|
||||
// JNZ 0x018723FF <- 0x018723AF (75 4E): both taken -> no co-op search
|
||||
// 75 4E -> 90 90: the co-op search goes on; the summon builder's own cap (4 cooperators,
|
||||
// ApplyBbscHunterCap) still decides who is let in.
|
||||
{"Hunters.KeepSearchingCoop", 0x018723AF, {0x75, 0x4E}, {0x90, 0x90}, Group::Hunters},
|
||||
}};
|
||||
|
||||
// Four-byte rewrites (a RIP-relative displacement), verified against the bytes that must be there.
|
||||
struct WideEdit {
|
||||
const char* name;
|
||||
u64 site;
|
||||
std::array<u8, 4> original;
|
||||
std::array<u8, 4> replacement;
|
||||
Group group;
|
||||
};
|
||||
|
||||
constexpr std::array<WideEdit, 11> WideEdits{{
|
||||
// Every blood echo reward the local player receives is computed by 0x01335400 and left in a
|
||||
// float at [[0x0553B130]+8]+0xF8. Halfway through:
|
||||
// MOV EAX, dword ptr [R15 + 0x78] ; the local player's character type
|
||||
// OR EAX, 0x8 / CMP EAX, 0x8
|
||||
// JZ 0x01335596 ; host (type 0) and type 8 skip the next three
|
||||
// VMOVSS XMM0, [RBP + -0x44]
|
||||
// VMULSS XMM0, XMM0, [RIP + 0x035F058B] <- 0x01335589, disp32 at 0x0133558D
|
||||
// VMOVSS [RBP + -0x44], XMM0 ; that address is 0x04925B1C = 0.5
|
||||
// Skipping the multiply (JZ -> JMP, builds up to 39A08659) did not help: a guest still got
|
||||
// exactly half the host's echoes for the same kill (+671 against +1341, both players NG+,
|
||||
// 2026-09-24). That is because 0x01335400 is only the EVENT reward (registered as a Lua/event
|
||||
// function at 0x013421AC); ordinary kills go through 0x018FC860, below. Doubling here
|
||||
// (build 43B41805) changed nothing for kills either, and would have paid a guest 2x for event
|
||||
// rewards, so the multiply now reads 1.0 - the float at 0x04925B20, right after the 0.5.
|
||||
// New displacement: 0x04925B20 - 0x01335591 = 0x035F058F.
|
||||
{"Echoes.GuestEventFull", 0x0133558D, {0x8B, 0x05, 0x5F, 0x03}, {0x8F, 0x05, 0x5F, 0x03},
|
||||
Group::Echoes},
|
||||
// The kill reward: 0x018FC860(rate, chr, enemy, bonus, guest_share), called by 0x018C5070
|
||||
// with guest_share = [[0x05556678]+0x16F8]+0x98 != -1 || SprjSessionManager+0x124 == 6 (a
|
||||
// summoned guest), multiplies by 0.5 when guest_share is set:
|
||||
// 0x018FC947 VMOVSS XMM3, [RIP + 0x0302B571] ; 0x04927EC0 = 0.5
|
||||
// and pays it through chr->vtable[0x3D0]. Pointing the load at 0x04927EBC (1.0, the float
|
||||
// just before) pays a guest what the host gets: +671 against +1341 in every 2026-09-24 test.
|
||||
// New displacement: 0x04927EBC - 0x018FC94F = 0x0302B56D.
|
||||
{"Echoes.GuestKillFull", 0x018FC94B, {0x71, 0xB5, 0x02, 0x03}, {0x6D, 0xB5, 0x02, 0x03},
|
||||
Group::Echoes},
|
||||
// Guest item pickup, part 1. 0x017D9E70(item manager, &item id, on) makes an item pickable
|
||||
// (+0x48 bit 0x40, which the pickup prompt search 0x017D9FB0 requires). For map treasures
|
||||
// (bit 0x80) it refuses a summoned guest with the same two tests as the echo halving:
|
||||
// CMP dword ptr [RCX + 0x124], 0x6 ; JZ return 0 (0F 84 B6000000 at 0x017D9EE7)
|
||||
// CMP dword ptr [R15 + 0x98], -1 ; JNZ return 0 (0F 85 A8000000 at 0x017D9EF5)
|
||||
// Zeroing both displacements makes each jump land on the next instruction, so the treasure is
|
||||
// enabled for the guest exactly as for the host.
|
||||
{"GuestItems.ShowTreasure", 0x017D9EE9, {0xB6, 0x00, 0x00, 0x00}, {0x00, 0x00, 0x00, 0x00},
|
||||
Group::GuestItems},
|
||||
{"GuestItems.ShowTreasureShare", 0x017D9EF7, {0xA8, 0x00, 0x00, 0x00},
|
||||
{0x00, 0x00, 0x00, 0x00}, Group::GuestItems},
|
||||
// Part 3, second half: the create function 0x017DE870 itself starts with
|
||||
// CMP dword ptr [RAX + 0x124], 0x6 ; JZ 0x017DEB34 (return) (0F 84 5C020000 at 0x017DE8D2)
|
||||
// A zero displacement lands the jump on the next instruction, so the guest's item is made.
|
||||
// It then only broadcasts the new item when hosting (+0x124 == 3), so a guest sends nothing.
|
||||
{"GuestItems.CreateOwn", 0x017DE8D4, {0x5C, 0x02, 0x00, 0x00}, {0x00, 0x00, 0x00, 0x00},
|
||||
Group::GuestItems},
|
||||
// Part 5: placing the map's treasures, 0x017DC300 (see ByteEdits, part 5):
|
||||
// CMP dword ptr [RAX + 0x124], 0x6 ; JZ 0x017DD081 (0F 84 A50C0000 at 0x017DC3D6)
|
||||
// CMP dword ptr [RDX + 0x98], -1 ; JNZ 0x017DD081 (0F 85 980C0000 at 0x017DC3E3)
|
||||
// 0x017DD081 is the end of the function: a guest placed no treasure at all. Zero
|
||||
// displacements fall through, so the guest's game places them from its own map load. Which
|
||||
// are shown is decided by the pickup flags (guest loot view in bloodborne_re.cpp); the
|
||||
// character type table 0x047348A0 used right after allows type 1 (co-op phantom).
|
||||
{"GuestItems.PlaceTreasures", 0x017DC3D8, {0xA5, 0x0C, 0x00, 0x00}, {0x00, 0x00, 0x00, 0x00},
|
||||
Group::GuestItems},
|
||||
{"GuestItems.PlaceTreasuresAway", 0x017DC3E5, {0x98, 0x0C, 0x00, 0x00},
|
||||
{0x00, 0x00, 0x00, 0x00}, Group::GuestItems},
|
||||
// Report 2026-10-07: "cannot use the bells in the Dream". The item availability check 0x0157F200
|
||||
// gives the bells their own branches by goods id: CMP R12D, 0xCD / 0xE1 (Small Resonant /
|
||||
// Sinister Bell) and at 0x0157F855 CMP EBX, 0xC8 (Beckoning Bell, 81 FB C8000000) - the branch
|
||||
// that holds the seamless patches letting a host ring it where vanilla says no (0x0157F8C8,
|
||||
// 0x0157F960). The kit's Hunt Bell is goods 4016 (a copy of 200), so it never got there; the
|
||||
// vanilla bell itself is disabled by the kit. Its id goes into the compare: 4016 = 0x00000FB0.
|
||||
// (The Kin Bell already works in the Dream through the general path - left as it is.)
|
||||
{"KitBells.HuntBellChecks", 0x0157F857, {0xC8, 0x00, 0x00, 0x00}, {0xB0, 0x0F, 0x00, 0x00},
|
||||
Group::KitBells},
|
||||
// Three players, 2026-10-08 (host, guest cls_amg; a tester's game had crashed but his
|
||||
// sign was still listed): the host summoned the dead sign and sat in CreateWait for 6 minutes,
|
||||
// then its own game raised "OnIrregularLeaveSession" (0x017897DA) and ended the session for the
|
||||
// guest too. That is the reliable-packet send 0x017894E0: a packet it cannot deliver to a member
|
||||
// is queued (list [SprjSessionManager]+0x188, counts at +0x30, plus +0x1B0), and once
|
||||
// CMP EAX, 0x3E9 <- 0x017897AD (3D E9 03 00 00)
|
||||
// 1001 are waiting it leaves. A member that never connects fills it at ~3 a second. The limit
|
||||
// goes to 30000 (~2-3 hours); "BBSC send backlog:" lines log a queue that keeps growing.
|
||||
{"SendBacklog.Limit", 0x017897AE, {0xE9, 0x03, 0x00, 0x00}, {0x30, 0x75, 0x00, 0x00},
|
||||
Group::SendBacklog},
|
||||
// Five hunters, part 2 (test 2026-10-08 on mod 37: with Hunters.KeepSearchingCoop the host's
|
||||
// sign lists DID hold a third co-op sign - 3 signs, 2742-2745 bytes - but no summon was ever
|
||||
// built for it). After the search, the same slot table decides which sign types may be taken:
|
||||
// both co-op slots taken -> MOV ESI, 0xFFF7FF7F (co-op types masked out), else 0xFFFFFFFF
|
||||
// at 0x0187304A (sign search 0x01872360) and 0x01879382 (0x01878D90). The mask stays all-ones;
|
||||
// the summon builder's own cap (4 cooperators) still decides who gets in.
|
||||
{"Hunters.CoopMaskSearch", 0x0187304B, {0x7F, 0xFF, 0xF7, 0xFF}, {0xFF, 0xFF, 0xFF, 0xFF},
|
||||
Group::Hunters},
|
||||
{"Hunters.CoopMaskSelect", 0x01879383, {0x7F, 0xFF, 0xF7, 0xFF}, {0xFF, 0xFF, 0xFF, 0xFF},
|
||||
Group::Hunters},
|
||||
}};
|
||||
|
||||
// Function entries replaced by a jmp to a sibling function with the same arguments.
|
||||
struct EntryJump {
|
||||
const char* name;
|
||||
u64 site;
|
||||
std::array<u8, 5> prologue; // expected first 5 bytes (whole instructions)
|
||||
u64 target;
|
||||
Group group;
|
||||
};
|
||||
|
||||
constexpr std::array<EntryJump, 1> EntryJumps{{
|
||||
// Lua_MultiWall(id) (0x0138B720) shows the co-op fog wall entity: 0x013CABD0(id, 1) draw on,
|
||||
// 0x013CA580(id, 1) collision on, 0x01315FA0(id + 1) its effect. Lua_InvalidMultiWall (0x0138B890)
|
||||
// is the same function with 0 / 0x01315E50, i.e. hides the wall. Jumping there keeps every wall
|
||||
// hidden and walkable (CONFIRMED statically from both listings, 2026-09-17).
|
||||
{"Lua_MultiWall", 0x0138B720, {0x55, 0x48, 0x89, 0xE5, 0x53}, 0x0138B890, Group::NoFogWalls},
|
||||
}};
|
||||
|
||||
// ---- Which groups are on (same switches in the emulator and the mod DLL) ----
|
||||
|
||||
inline bool EnvFlag(const char* name) {
|
||||
return SwitchEnabled(name);
|
||||
}
|
||||
|
||||
inline bool GroupEnabled(Group group) {
|
||||
switch (group) {
|
||||
case Group::Stage:
|
||||
case Group::Revive:
|
||||
return true;
|
||||
case Group::Boss:
|
||||
return EnvFlag("SHADPS4_BBSC_PERSIST_BOSS");
|
||||
case Group::MatchCheck:
|
||||
return EnvFlag("SHADPS4_BBSC_PERSIST_MATCHCHECK");
|
||||
case Group::Lamp:
|
||||
return EnvFlag("SHADPS4_BBSC_LAMP");
|
||||
case Group::Tasks:
|
||||
return EnvFlag("SHADPS4_BBSC_PERSIST_TASKS");
|
||||
case Group::HostDead:
|
||||
return EnvFlag("SHADPS4_BBSC_PERSIST_HOSTDEAD");
|
||||
case Group::NoFogWalls:
|
||||
return EnvFlag("SHADPS4_BBSC_NO_FOG_WALLS");
|
||||
case Group::LeaveLimit:
|
||||
return EnvFlag("SHADPS4_BBSC_SKIP_LEAVE_LIMIT");
|
||||
case Group::Spectate:
|
||||
return EnvFlag("SHADPS4_BBSC_SPECTATE");
|
||||
case Group::Echoes:
|
||||
return EnvFlag("SHADPS4_BBSC_FULL_ECHOES");
|
||||
case Group::GuestItems:
|
||||
return EnvFlag("SHADPS4_BBSC_GUEST_ITEMS");
|
||||
case Group::SolidNpcs:
|
||||
return EnvFlag("SHADPS4_BBSC_SOLID_NPCS");
|
||||
case Group::Scaling:
|
||||
return EnvFlag("SHADPS4_BBSC_SCALING");
|
||||
case Group::KitBells:
|
||||
return true; // the kit is always given (bloodborne_re.cpp, PrepareBbscKitRows)
|
||||
case Group::SendBacklog:
|
||||
return true;
|
||||
case Group::Hunters:
|
||||
return EnvFlag("SHADPS4_BBSC_FIVE_HUNTERS");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Groups a suspension (quit to title, partner gone) switches off so the vanilla teardown can run.
|
||||
// Only the ones that keep a session alive: the echo fix, the lamp fix, the fog walls, the death
|
||||
// reload retarget and host-death handling have nothing to do with ending a session, and switching
|
||||
// them off for 20 s halved echoes, blocked lamps and let fog walls appear (code review B2).
|
||||
inline bool IsSessionGroup(Group group) {
|
||||
switch (group) {
|
||||
case Group::Stage:
|
||||
case Group::Revive:
|
||||
case Group::Boss:
|
||||
case Group::MatchCheck:
|
||||
case Group::Tasks:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// The lamp edits follow the player's role at runtime instead of being on all the time: a guest
|
||||
// must never get the lamp prompt (vanilla never allows it, and a guest's lamp travel starts a load
|
||||
// in the host's world), and single player must keep the vanilla gates (code review B3/B4).
|
||||
inline bool IsRoleGroup(Group group) {
|
||||
return group == Group::Lamp;
|
||||
}
|
||||
|
||||
enum class Scope : u8 { Install, Session, Role };
|
||||
|
||||
inline bool InScope(Group group, Scope scope) {
|
||||
switch (scope) {
|
||||
case Scope::Install:
|
||||
return !IsRoleGroup(group); // role groups start in their vanilla state
|
||||
case Scope::Session:
|
||||
return IsSessionGroup(group);
|
||||
case Scope::Role:
|
||||
return IsRoleGroup(group);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
inline std::array<u8, 5> EncodeRel32(u8 opcode, u64 site, u64 target) {
|
||||
const std::int64_t displacement = static_cast<std::int64_t>(target) - static_cast<std::int64_t>(site + 5);
|
||||
const std::int32_t rel = static_cast<std::int32_t>(displacement);
|
||||
std::array<u8, 5> bytes{opcode};
|
||||
std::memcpy(bytes.data() + 1, &rel, sizeof(rel));
|
||||
return bytes;
|
||||
}
|
||||
|
||||
} // namespace Core::Bbsc::PatchTables
|
||||
@@ -0,0 +1,263 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include "core/bbsc_persistence.h"
|
||||
#include "core/bbsc_patch_tables.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string_view>
|
||||
|
||||
#include "common/logging/log.h"
|
||||
#include "common/memory_patcher.h"
|
||||
#include "common/singleton.h"
|
||||
#include "common/types.h"
|
||||
#include "core/file_format/psf.h"
|
||||
|
||||
namespace Core::Bbsc {
|
||||
namespace {
|
||||
|
||||
using namespace PatchTables;
|
||||
|
||||
// Set once every enabled site has been byte-verified, so the runtime toggle can never write into
|
||||
// an image the patches were rejected for.
|
||||
bool patches_verified = false;
|
||||
bool session_patches_on = false;
|
||||
bool role_patches_on = false;
|
||||
|
||||
// Writes every enabled site in `scope` in its patched or its original form. Returns how many were
|
||||
// written.
|
||||
int WritePatches(bool enable, Scope scope) {
|
||||
const u64 base = MemoryPatcher::g_eboot_address;
|
||||
if (!patches_verified || base == 0) {
|
||||
return 0;
|
||||
}
|
||||
if (scope == Scope::Session && session_patches_on == enable) {
|
||||
return 0;
|
||||
}
|
||||
if (scope == Scope::Role && role_patches_on == enable) {
|
||||
return 0;
|
||||
}
|
||||
int count = 0;
|
||||
for (const auto& entry : EntryJumps) {
|
||||
if (!GroupEnabled(entry.group) || !InScope(entry.group, scope)) {
|
||||
continue;
|
||||
}
|
||||
auto* site = reinterpret_cast<u8*>(base + entry.site);
|
||||
if (enable) {
|
||||
const auto patched = EncodeRel32(0xE9, entry.site, entry.target);
|
||||
std::memcpy(site, patched.data(), patched.size());
|
||||
} else {
|
||||
std::memcpy(site, entry.prologue.data(), entry.prologue.size());
|
||||
}
|
||||
++count;
|
||||
}
|
||||
for (const auto& entry : Retargets) {
|
||||
if (!GroupEnabled(entry.group) || !InScope(entry.group, scope)) {
|
||||
continue;
|
||||
}
|
||||
const auto bytes = EncodeRel32(entry.opcode, entry.site,
|
||||
enable ? entry.target : entry.original);
|
||||
std::memcpy(reinterpret_cast<u8*>(base + entry.site), bytes.data(), bytes.size());
|
||||
++count;
|
||||
}
|
||||
for (const auto& entry : EntryReturns) {
|
||||
if (!GroupEnabled(entry.group) || !InScope(entry.group, scope)) {
|
||||
continue;
|
||||
}
|
||||
// Only the first byte changes: 0xC3 (ret) or the function's real first instruction byte.
|
||||
*reinterpret_cast<u8*>(base + entry.site) = enable ? 0xC3 : entry.prologue[0];
|
||||
++count;
|
||||
}
|
||||
for (const auto& entry : ByteEdits) {
|
||||
if (!GroupEnabled(entry.group) || !InScope(entry.group, scope)) {
|
||||
continue;
|
||||
}
|
||||
const auto& bytes = enable ? entry.replacement : entry.original;
|
||||
std::memcpy(reinterpret_cast<u8*>(base + entry.site), bytes.data(), bytes.size());
|
||||
++count;
|
||||
}
|
||||
for (const auto& entry : WideEdits) {
|
||||
if (!GroupEnabled(entry.group) || !InScope(entry.group, scope)) {
|
||||
continue;
|
||||
}
|
||||
const auto& bytes = enable ? entry.replacement : entry.original;
|
||||
std::memcpy(reinterpret_cast<u8*>(base + entry.site), bytes.data(), bytes.size());
|
||||
++count;
|
||||
}
|
||||
if (scope == Scope::Install) {
|
||||
session_patches_on = enable;
|
||||
} else if (scope == Scope::Session) {
|
||||
session_patches_on = enable;
|
||||
} else {
|
||||
role_patches_on = enable;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void SetPersistentSessionPatches(bool enabled) {
|
||||
const int count = WritePatches(enabled, Scope::Session);
|
||||
if (count != 0) {
|
||||
LOG_INFO(Debug, "BBSC persistence {} ({} session sites; echoes, lamp, fog walls and "
|
||||
"deaths untouched)",
|
||||
enabled ? "re-applied" : "suspended", count);
|
||||
}
|
||||
}
|
||||
|
||||
void SetHostLampPatches(bool hosting) {
|
||||
const int count = WritePatches(hosting, Scope::Role);
|
||||
if (count != 0) {
|
||||
LOG_INFO(Debug, "BBSC lamp: {} ({} sites)",
|
||||
hosting ? "prompt allowed (hosting a session)"
|
||||
: "vanilla gates (solo or guest - a guest never gets the lamp)",
|
||||
count);
|
||||
}
|
||||
}
|
||||
|
||||
void InstallPersistentSessionPatches() {
|
||||
static bool attempted = false;
|
||||
if (attempted) {
|
||||
return;
|
||||
}
|
||||
if (!EnvFlag("SHADPS4_BBSC_PERSIST")) {
|
||||
LOG_INFO(Debug, "BBSC persistence declined: SHADPS4_BBSC_PERSIST is off");
|
||||
return;
|
||||
}
|
||||
attempted = true;
|
||||
|
||||
if (MemoryPatcher::g_game_serial != "CUSA03173") {
|
||||
LOG_INFO(Debug, "BBSC persistence declined: this is {}, not CUSA03173",
|
||||
MemoryPatcher::g_game_serial.empty() ? "an unknown title"
|
||||
: MemoryPatcher::g_game_serial);
|
||||
return;
|
||||
}
|
||||
const auto* param_sfo = Common::Singleton<PSF>::Instance();
|
||||
const std::string_view app_version = param_sfo->GetString("APP_VER").value_or("Unknown");
|
||||
if (app_version != "01.09") {
|
||||
LOG_ERROR(Debug, "BBSC persistence requires CUSA03173 01.09; loaded version is {}",
|
||||
app_version);
|
||||
return;
|
||||
}
|
||||
|
||||
const u64 base = MemoryPatcher::g_eboot_address;
|
||||
const u64 image_size = MemoryPatcher::g_eboot_image_size;
|
||||
if (base == 0) {
|
||||
LOG_ERROR(Debug, "BBSC persistence declined: the eboot image base is not known yet");
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify every enabled site before writing any, so a mismatch leaves the game untouched.
|
||||
for (const auto& entry : Retargets) {
|
||||
if (!GroupEnabled(entry.group)) {
|
||||
continue;
|
||||
}
|
||||
if (entry.site + 5 > image_size) {
|
||||
LOG_ERROR(Debug, "BBSC persistence site {} is outside the image", entry.name);
|
||||
return;
|
||||
}
|
||||
const auto original = EncodeRel32(entry.opcode, entry.site, entry.original);
|
||||
const auto patched = EncodeRel32(entry.opcode, entry.site, entry.target);
|
||||
const auto* actual = reinterpret_cast<const u8*>(base + entry.site);
|
||||
if (std::memcmp(actual, original.data(), 5) != 0 &&
|
||||
std::memcmp(actual, patched.data(), 5) != 0) {
|
||||
LOG_ERROR(Debug,
|
||||
"BBSC persistence signature mismatch at {} {:#x}; no persistence patches "
|
||||
"applied",
|
||||
entry.name, entry.site);
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const auto& entry : EntryReturns) {
|
||||
if (!GroupEnabled(entry.group)) {
|
||||
continue;
|
||||
}
|
||||
if (entry.site + entry.prologue.size() > image_size) {
|
||||
LOG_ERROR(Debug, "BBSC persistence site {} is outside the image", entry.name);
|
||||
return;
|
||||
}
|
||||
const auto* actual = reinterpret_cast<const u8*>(base + entry.site);
|
||||
if (actual[0] != 0xC3 &&
|
||||
std::memcmp(actual, entry.prologue.data(), entry.prologue.size()) != 0) {
|
||||
LOG_ERROR(Debug,
|
||||
"BBSC persistence signature mismatch at {} {:#x}; no persistence patches "
|
||||
"applied",
|
||||
entry.name, entry.site);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (const auto& entry : EntryJumps) {
|
||||
if (!GroupEnabled(entry.group)) {
|
||||
continue;
|
||||
}
|
||||
if (entry.site + entry.prologue.size() > image_size || entry.target >= image_size) {
|
||||
LOG_ERROR(Debug, "BBSC persistence site {} is outside the image", entry.name);
|
||||
return;
|
||||
}
|
||||
const auto patched = EncodeRel32(0xE9, entry.site, entry.target);
|
||||
const auto* actual = reinterpret_cast<const u8*>(base + entry.site);
|
||||
if (std::memcmp(actual, patched.data(), patched.size()) != 0 &&
|
||||
std::memcmp(actual, entry.prologue.data(), entry.prologue.size()) != 0) {
|
||||
LOG_ERROR(Debug,
|
||||
"BBSC persistence signature mismatch at {} {:#x}; no persistence patches "
|
||||
"applied",
|
||||
entry.name, entry.site);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (const auto& entry : ByteEdits) {
|
||||
if (!GroupEnabled(entry.group)) {
|
||||
continue;
|
||||
}
|
||||
if (entry.site + entry.original.size() > image_size) {
|
||||
LOG_ERROR(Debug, "BBSC persistence site {} is outside the image", entry.name);
|
||||
return;
|
||||
}
|
||||
const auto* actual = reinterpret_cast<const u8*>(base + entry.site);
|
||||
if (std::memcmp(actual, entry.original.data(), entry.original.size()) != 0 &&
|
||||
std::memcmp(actual, entry.replacement.data(), entry.replacement.size()) != 0) {
|
||||
LOG_ERROR(Debug,
|
||||
"BBSC persistence signature mismatch at {} {:#x}; no persistence patches "
|
||||
"applied",
|
||||
entry.name, entry.site);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (const auto& entry : WideEdits) {
|
||||
if (!GroupEnabled(entry.group)) {
|
||||
continue;
|
||||
}
|
||||
if (entry.site + entry.original.size() > image_size) {
|
||||
LOG_ERROR(Debug, "BBSC persistence site {} is outside the image", entry.name);
|
||||
return;
|
||||
}
|
||||
const auto* actual = reinterpret_cast<const u8*>(base + entry.site);
|
||||
if (std::memcmp(actual, entry.original.data(), entry.original.size()) != 0 &&
|
||||
std::memcmp(actual, entry.replacement.data(), entry.replacement.size()) != 0) {
|
||||
LOG_ERROR(Debug,
|
||||
"BBSC persistence signature mismatch at {} {:#x}; no persistence patches "
|
||||
"applied",
|
||||
entry.name, entry.site);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
patches_verified = true;
|
||||
const int applied = WritePatches(true, Scope::Install);
|
||||
LOG_INFO(Debug,
|
||||
"BBSC persistence enabled ({} sites; boss={}, matching-check={}, lamp={}, "
|
||||
"keep-tasks={}, ignore-host-death={}, no-fog-walls={}, skip-leave-limit={}, "
|
||||
"death-without-reload={}, guest-items={}, solid-npcs={})",
|
||||
applied, GroupEnabled(Group::Boss), GroupEnabled(Group::MatchCheck),
|
||||
GroupEnabled(Group::Lamp), GroupEnabled(Group::Tasks),
|
||||
GroupEnabled(Group::HostDead), GroupEnabled(Group::NoFogWalls),
|
||||
GroupEnabled(Group::LeaveLimit), GroupEnabled(Group::Spectate),
|
||||
GroupEnabled(Group::GuestItems), GroupEnabled(Group::SolidNpcs));
|
||||
}
|
||||
|
||||
} // namespace Core::Bbsc
|
||||
@@ -0,0 +1,102 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <string_view>
|
||||
|
||||
namespace Core::Bbsc {
|
||||
|
||||
// BBSC build defaults: the co-op features confirmed working in game (2026-09-16) are on without
|
||||
// any environment variable, so the build runs straight from a launcher. Everything still only
|
||||
// applies to CUSA03173 01.09 (checked where each feature installs). Experimental switches stay
|
||||
// off, and so is the boot intro (SHADPS4_BBSC_INTRO=1 brings it back). Setting a variable always wins: "0" turns a default off, any other value turns it on.
|
||||
inline constexpr std::array<std::string_view, 31> DefaultOnSwitches{
|
||||
"SHADPS4_BBSC_SHARED_PROGRESS", // merge the host's progress into the guest's world (adds only)
|
||||
"SHADPS4_BBSC_WARP_TOGETHER", // the guest follows the host through a lamp warp
|
||||
"SHADPS4_BBSC_WORLD_SYNC", // ...and loads the host's map with the host's world flags
|
||||
"SHADPS4_BBSC_LOAD_COVER", // the guest's summon/follow loads behind a smooth black fade
|
||||
"SHADPS4_BBSC_FULL_ECHOES", // a summoned guest is paid the host's rate, not half
|
||||
"SHADPS4_BBSC_GUEST_ITEMS", // a guest picks up and keeps items in the host's world
|
||||
"SHADPS4_BBSC_SOLID_NPCS", // a guest sees NPCs solid, not as grey wandering ghosts
|
||||
"SHADPS4_BBSC_FLOOR_GUARD", // no crash when a character's floor was unloaded
|
||||
"SHADPS4_BBSC_DREAM", // a guest uses the Doll, workshop and messengers in the host's Dream
|
||||
"SHADPS4_BBSC_DEATH_PENALTY", // a kept death still drops a bloodstain and the echoes
|
||||
"SHADPS4_BBSC_TRACE_WALLS", // log which map objects get shown/made solid (fog walls)
|
||||
"SHADPS4_BBSC_LAMP", // the lamp prompt works in co-op (clears the +0x4B gate)
|
||||
"SHADPS4_BBSC_NO_HP_PENALTY", // clears the summoned guest's 30% max HP cut (effect 9006)
|
||||
"SHADPS4_BBSC_NO_FOG_WALLS", // co-op area fog walls hidden, leaving the area allowed
|
||||
"SHADPS4_BBSC_MESSAGES", // co-op texts that fit seamless play (no "Returning to your world")
|
||||
"SHADPS4_BBSC_CONNECT_FIRST", // cross-map summon: join first, one load into host map
|
||||
"SHADPS4_BBSC_WARP_JOIN", // after a cross-map join, one more real reload (lighting)
|
||||
"SHADPS4_BLOODBORNE_SEAMLESS_COOP", // Wozzardman's seamless summon
|
||||
"SHADPS4_BLOODBORNE_RE_TRACE", // installs the BBSC hooks (and the trace logs)
|
||||
"SHADPS4_CAPTURE_BLOODBORNE_SUMMON", // summon capture files for collect-logs
|
||||
"SHADPS4_BBSC_PERSIST", // keep the session through loads and deaths
|
||||
"SHADPS4_BBSC_PERSIST_MATCHCHECK", // ignore the post-load matching check kick
|
||||
"SHADPS4_BBSC_REJOIN", // guest rejoin / reinsert after a kept death
|
||||
"SHADPS4_BBSC_PERSIST_TASKS", // keep member tasks across a kept load
|
||||
"SHADPS4_BBSC_PERSIST_HOSTDEAD", // the guest stays when the host dies
|
||||
"SHADPS4_BBSC_SPECTATE", // respawn in place / spectate, join teleport
|
||||
"SHADPS4_BBSC_FREE_BELL", // the Beckoning Bell costs no Insight
|
||||
"SHADPS4_BBSC_FIVE_HUNTERS", // the host may summon up to 4 guests (5 hunters)
|
||||
"SHADPS4_BBSC_CINEMATIC", // F1: free camera for filming (keyboard + mouse)
|
||||
"SHADPS4_BBSC_SCALING", // enemy/boss HP, damage, stagger per guest (like ERSC)
|
||||
"SHADPS4_BBSC_INVADE_ANYWHERE", // every world open to invaders (Bell Maiden SpEffect 9020)
|
||||
};
|
||||
|
||||
// False in the vanilla build (CMake BBSC_VANILLA): the emulator carries no BBSC of its own and
|
||||
// BBSC runs as the bbsc.dll mod, which compiles this header itself (BBSC_MOD) with BuiltIn true.
|
||||
#if defined(BBSC_VANILLA) && !defined(BBSC_MOD)
|
||||
inline constexpr bool BuiltIn = false;
|
||||
#else
|
||||
inline constexpr bool BuiltIn = true;
|
||||
#endif
|
||||
|
||||
inline bool SwitchEnabled(const char* name) {
|
||||
if constexpr (!BuiltIn) {
|
||||
// Every BBSC feature is off, whatever the environment says. The three real opt-out
|
||||
// switches count as set, so the emulator's own seamless-summon helpers (fast sign,
|
||||
// placement header) stay out of the way of the mod's.
|
||||
const std::string_view key{name};
|
||||
return key == "SHADPS4_BBSC_NO_PLACEMENT_HEADER" || key == "SHADPS4_BBSC_NO_FAST_SIGN" ||
|
||||
key == "SHADPS4_BBSC_NO_GUEST_HOOKS";
|
||||
}
|
||||
const char* value = std::getenv(name);
|
||||
if (value == nullptr || value[0] == '\0') {
|
||||
for (const std::string_view on : DefaultOnSwitches) {
|
||||
if (on == name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return std::string_view{value} != "0";
|
||||
}
|
||||
|
||||
// Return-only guest function used as the target for skipped remote-player cleanup calls.
|
||||
// Observed by the RE trace as "BBSC.Persist.SkippedCleanup" to record every persistence decision.
|
||||
inline constexpr std::uint64_t PersistNoOpOffset = 0x013001E0;
|
||||
|
||||
// Phase 1 (docs/PHASE1-DESIGN.md, Option A): keep Bloodborne's co-op session alive across
|
||||
// structural session ends (stage loads, revive, optionally boss clear and OnMatchingCheck) by
|
||||
// retargeting the call sites of CSMultiPlayMan::Stop and WorldChrMan remote cleanup.
|
||||
// On by default (SHADPS4_BBSC_PERSIST=0 disables). CUSA03173 01.09 only; every site is byte-verified.
|
||||
void InstallPersistentSessionPatches();
|
||||
|
||||
// Turns the applied patches off and on again at runtime. A deliberate quit to the title has to
|
||||
// tear the session down the way the game intends - otherwise the other player keeps a phantom
|
||||
// partner and has to close the game (reported 2026-09-20). Reverting the patches for a few
|
||||
// seconds lets the vanilla teardown run, then they go back on. Safe to call repeatedly; does
|
||||
// nothing when the patches were never applied.
|
||||
void SetPersistentSessionPatches(bool enabled);
|
||||
|
||||
// The lamp edits (prompt gate +0x49 and the character gate) are only on while this player hosts a
|
||||
// session. Called from the game tick whenever the role changes; the lamp stays vanilla in single
|
||||
// player and for a guest.
|
||||
void SetHostLampPatches(bool hosting);
|
||||
|
||||
} // namespace Core::Bbsc
|
||||
@@ -0,0 +1,83 @@
|
||||
// Generated by bbsc/translations/gen_translations.py from bbsc/translations/*.json - do not
|
||||
// edit. {english, {es, fr, de, it, pt, ru}}
|
||||
{u"<?joinName?> joined your game.", {u"<?joinName?> se ha unido a tu partida.", u"<?joinName?> a rejoint votre partie.", u"<?joinName?> ist deinem Spiel beigetreten.", u"<?joinName?> si è unito alla tua partita.", u"<?joinName?> entrou no seu jogo.", u"Игрок <?joinName?> вошёл в вашу игру."}},
|
||||
{u"You joined <?hostName?>'s game.", {u"Te has unido a la partida de <?hostName?>.", u"Vous avez rejoint la partie de <?hostName?>.", u"Du bist dem Spiel von <?hostName?> beigetreten.", u"Ti sei unito alla partita di <?hostName?>.", u"Você entrou no jogo de <?hostName?>.", u"Вы вошли в игру игрока <?hostName?>."}},
|
||||
{u"You died.", {u"Has muerto.", u"Vous êtes mort.", u"Du bist gestorben.", u"Sei morto.", u"Você morreu.", u"Вы погибли."}},
|
||||
{u"The host died.\nYou stay in their game.", {u"El anfitrión ha muerto.\nSigues en su partida.", u"L'hôte est mort.\nVous restez dans sa partie.", u"Der Gastgeber ist gestorben.\nDu bleibst in seinem Spiel.", u"L'ospitante è morto.\nResti nella sua partita.", u"O anfitrião morreu.\nVocê continua no jogo dele.", u"Хозяин мира погиб.\nВы остаётесь в его игре."}},
|
||||
{u"<?deadName?> died.\nThey will respawn.", {u"<?deadName?> ha muerto.\nVolverá a aparecer.", u"<?deadName?> est mort.\nIl va réapparaître.", u"<?deadName?> ist gestorben.\nEr kehrt gleich zurück.", u"<?deadName?> è morto.\nRitornerà in vita.", u"<?deadName?> morreu.\nVai renascer.", u"Игрок <?deadName?> погиб.\nОн возродится."}},
|
||||
{u"The host <?hostName?> died.\nYou stay in their game.", {u"El anfitrión <?hostName?> ha muerto.\nSigues en su partida.", u"L'hôte <?hostName?> est mort.\nVous restez dans sa partie.", u"Gastgeber <?hostName?> ist gestorben.\nDu bleibst in seinem Spiel.", u"L'ospitante <?hostName?> è morto.\nResti nella sua partita.", u"O anfitrião <?hostName?> morreu.\nVocê continua no jogo dele.", u"Хозяин мира <?hostName?> погиб.\nВы остаётесь в его игре."}},
|
||||
{u"The host started the battle against\n{boss}.", {u"El anfitrión ha iniciado el combate contra\n{boss}.", u"L'hôte a engagé le combat contre\n{boss}.", u"Der Gastgeber hat den Kampf gegen\n{boss} begonnen.", u"L'ospitante ha iniziato lo scontro con\n{boss}.", u"O anfitrião iniciou a batalha contra\n{boss}.", u"Хозяин мира вступил в бой:\n{boss}."}},
|
||||
{u"The host started a boss battle.", {u"El anfitrión se enfrenta a un jefe.", u"L'hôte a engagé un combat de boss.", u"Der Gastgeber hat einen Bosskampf begonnen.", u"L'ospitante ha sfidato un boss.", u"O anfitrião iniciou uma batalha contra chefe.", u"Хозяин мира начал битву с боссом."}},
|
||||
{u"{boss} defeated.", {u"Jefe derrotado: {boss}.", u"{boss} a été vaincu.", u"{boss} besiegt.", u"{boss} sconfitto.", u"{boss} foi derrotado.", u"Босс повержен: {boss}."}},
|
||||
{u"Boss defeated.", {u"Jefe derrotado.", u"Boss vaincu.", u"Boss besiegt.", u"Boss sconfitto.", u"Chefe derrotado.", u"Босс повержен."}},
|
||||
{u"Invader <?joinName?> entered your world!", {u"¡El invasor <?joinName?> ha entrado en tu mundo!", u"L'envahisseur <?joinName?> est arrivé !", u"Eindringling <?joinName?> ist in deine Welt eingedrungen!", u"L'invasore <?joinName?> è entrato nel tuo mondo!", u"O invasor <?joinName?> entrou no seu mundo!", u"Захватчик <?joinName?> вторгся в ваш мир!"}},
|
||||
{u"A bell-ringing woman appeared.\nAn invader may come.", {u"Ha aparecido una mujer de la campana.\nPuede llegar un invasor.", u"Une sonneuse de cloche est apparue.\nUn envahisseur pourrait venir.", u"Eine Glockenfrau ist erschienen.\nEin Eindringling könnte kommen.", u"È apparsa una donna con la campana.\nPotrebbe arrivare un invasore.", u"Uma mulher do sino apareceu.\nUm invasor pode chegar.", u"Появилась женщина с колоколом.\nМожет прийти захватчик."}},
|
||||
{u"Invader <?deadName?> was killed.", {u"El invasor <?deadName?> ha muerto.", u"L'envahisseur <?deadName?> a été tué.", u"Eindringling <?deadName?> wurde getötet.", u"L'invasore <?deadName?> è stato ucciso.", u"O invasor <?deadName?> foi morto.", u"Захватчик <?deadName?> убит."}},
|
||||
{u"Invader <?leaveName?> left\nand went back to their world.", {u"El invasor <?leaveName?> se ha ido\ny ha vuelto a su mundo.", u"L'envahisseur <?leaveName?> est parti\net a regagné son monde.", u"Eindringling <?leaveName?> ist gegangen\nund in seine Welt zurückgekehrt.", u"L'invasore <?leaveName?> se n'è andato\ne è tornato nel suo mondo.", u"O invasor <?leaveName?> saiu\ne voltou ao próprio mundo.", u"Захватчик <?leaveName?> ушёл\nи вернулся в свой мир."}},
|
||||
{u"You invaded <?hostName?>'s world.", {u"Has invadido el mundo de <?hostName?>.", u"Vous avez envahi le monde de <?hostName?>.", u"Du bist in die Welt von <?hostName?> eingedrungen.", u"Hai invaso il mondo di <?hostName?>.", u"Você invadiu o mundo de <?hostName?>.", u"Вы вторглись в мир игрока <?hostName?>."}},
|
||||
{u"A player answered your bell.\nJoining...", {u"Un jugador ha respondido a tu campana.\nConectando...", u"Un joueur a répondu.\nConnexion...", u"Ein Spieler hat deine Glocke gehört.\nBeitritt läuft ...", u"Un giocatore ha risposto alla campana.\nIngresso in corso...", u"Um jogador respondeu ao seu sino.\nEntrando...", u"Игрок откликнулся на ваш колокол.\nПодключение..."}},
|
||||
{u"Found a host.\nJoining their game...", {u"Anfitrión encontrado.\nUniéndote a su partida...", u"Hôte trouvé.\nConnexion à sa partie...", u"Gastgeber gefunden.\nTrete seinem Spiel bei ...", u"Ospitante trovato.\nIngresso nella sua partita...", u"Anfitrião encontrado.\nEntrando no jogo dele...", u"Хозяин мира найден.\nПодключение к его игре..."}},
|
||||
{u"Ring the Hunt Bell\nto call other players?", {u"¿Tocar la Campana de cacería\npara llamar a otros jugadores?", u"Sonner la Cloche de chasse\npour appeler des joueurs ?", u"Die Jagdglocke läuten, um\nandere Spieler zu rufen?", u"Suonare la Campana della caccia\nper chiamare altri giocatori?", u"Tocar o Sino da Caçada\npara chamar outros jogadores?", u"Позвонить в Колокол охоты,\nчтобы позвать других игроков?"}},
|
||||
{u"Stop the Hunt Bell", {u"Silenciar la Campana de cacería", u"Arrêter la Cloche de chasse", u"Jagdglocke verstummen lassen", u"Ferma la Campana della caccia", u"Parar o Sino da Caçada", u"Заглушить Колокол охоты"}},
|
||||
{u"Stop the Kin Bell", {u"Silenciar la Campana fraterna", u"Arrêter la Cloche fraternelle", u"Sippenglocke verstummen lassen", u"Ferma la Campana fraterna", u"Parar o Sino Fraterno", u"Заглушить Колокол родства"}},
|
||||
{u"Stop the Bloodlust Bell", {u"Silenciar la Campana sanguinaria", u"Arrêter la Cloche sanguinaire", u"Blutrauschglocke verstummen lassen", u"Ferma la Campana sanguinaria", u"Parar o Sino Sanguinário", u"Заглушить Колокол кровожадности"}},
|
||||
{u"Hunt Bell rung.\nWaiting for players to join...", {u"Campana de cacería tocada.\nEsperando a que se unan jugadores...", u"Cloche de chasse sonnée.\nEn attente d'autres joueurs...", u"Jagdglocke geläutet.\nWarte auf Mitspieler ...", u"Campana della caccia suonata.\nIn attesa di altri giocatori...", u"Sino da Caçada tocado.\nAguardando outros jogadores...", u"Колокол охоты звонит.\nОжидание игроков..."}},
|
||||
{u"Kin Bell rung.\nLooking for a host to join...", {u"Campana fraterna tocada.\nBuscando un anfitrión al que unirte...", u"Cloche fraternelle sonnée.\nRecherche d'un hôte à rejoindre...", u"Sippenglocke geläutet.\nSuche einen Gastgeber ...", u"Campana fraterna suonata.\nRicerca di un ospitante...", u"Sino Fraterno tocado.\nProcurando um anfitrião...", u"Колокол родства звонит.\nПоиск хозяина мира..."}},
|
||||
{u"Bloodlust Bell rung.\nLooking for a world to invade...", {u"Campana sanguinaria tocada.\nBuscando un mundo que invadir...", u"Cloche sanguinaire sonnée.\nRecherche d'un monde à envahir...", u"Blutrauschglocke geläutet.\nSuche eine Welt zum Eindringen ...", u"Campana sanguinaria suonata.\nRicerca di un mondo da invadere...", u"Sino Sanguinário tocado.\nProcurando um mundo para invadir...", u"Колокол кровожадности звонит.\nПоиск мира для вторжения..."}},
|
||||
{u"Nobody found.\nRing the bell again to keep looking.", {u"No se ha encontrado a nadie.\nVuelve a tocarla para seguir buscando.", u"Personne trouvé.\nResonnez la cloche pour continuer.", u"Niemanden gefunden.\nLäute die Glocke erneut, um weiterzusuchen.", u"Nessuno trovato.\nSuona di nuovo per continuare a cercare.", u"Ninguém encontrado.\nToque o sino de novo para continuar.", u"Никого не найдено.\nПозвоните снова, чтобы искать дальше."}},
|
||||
{u"Someone is about to invade your world.", {u"Alguien está a punto de invadir tu mundo.", u"Quelqu'un s'apprête à envahir votre monde.", u"Jemand dringt gleich in deine Welt ein.", u"Qualcuno sta per invadere il tuo mondo.", u"Alguém está prestes a invadir seu mundo.", u"Кто-то готовится вторгнуться в ваш мир."}},
|
||||
{u"Found a world to invade.\nEntering...", {u"Mundo que invadir encontrado.\nEntrando...", u"Monde à envahir trouvé.\nEntrée en cours...", u"Welt zum Eindringen gefunden.\nBetrete sie ...", u"Trovato un mondo da invadere.\nIngresso in corso...", u"Mundo para invadir encontrado.\nEntrando...", u"Мир для вторжения найден.\nВторжение..."}},
|
||||
{u"Hunt Bell rung (password).\nWaiting for players to join...", {u"Campana de cacería tocada (contraseña).\nEsperando a que se unan jugadores...", u"Cloche de chasse sonnée (mot de passe).\nEn attente d'autres joueurs...", u"Jagdglocke geläutet (Passwort).\nWarte auf Mitspieler ...", u"Campana della caccia (password).\nIn attesa di altri giocatori...", u"Sino da Caçada tocado (senha).\nAguardando outros jogadores...", u"Колокол охоты звонит (пароль).\nОжидание игроков..."}},
|
||||
{u"Kin Bell rung (password).\nLooking for a host to join...", {u"Campana fraterna tocada (contraseña).\nBuscando un anfitrión al que unirte...", u"Cloche fraternelle sonnée (mot de passe).\nRecherche d'un hôte à rejoindre...", u"Sippenglocke geläutet (Passwort).\nSuche einen Gastgeber ...", u"Campana fraterna (password).\nRicerca di un ospitante...", u"Sino Fraterno tocado (senha).\nProcurando um anfitrião...", u"Колокол родства звонит (пароль).\nПоиск хозяина мира..."}},
|
||||
{u"Found a host,\nbut the beckoner is an Executioner, and thus an adversary.", {u"Anfitrión encontrado,\npero es un Verdugo y, por tanto, un adversario.", u"Hôte trouvé,\nmais c'est un Bourreau, et donc un adversaire.", u"Gastgeber gefunden,\ndoch der Rufende ist ein Henker und somit ein Gegner.", u"Ospitante trovato,\nma è un Giustiziere, e dunque un avversario.", u"Anfitrião encontrado,\nmas ele é um Executor e, portanto, um adversário.", u"Хозяин мира найден,\nно призвавший — Палач, а значит, противник."}},
|
||||
{u"Found a host,\nbut the beckoner is a Vileblood, and thus an adversary.", {u"Anfitrión encontrado,\npero es de la Sangre Vil y, por tanto, un adversario.", u"Hôte trouvé,\nmais c'est un Sang-vicié, et donc un adversaire.", u"Gastgeber gefunden,\ndoch der Rufende ist ein Gemeinblut und somit ein Gegner.", u"Ospitante trovato,\nma è un Sangue Infetto, e dunque un avversario.", u"Anfitrião encontrado,\nmas ele é um Sangue Vil e, portanto, um adversário.", u"Хозяин мира найден,\nно призвавший — Нечистокровный, а значит, противник."}},
|
||||
{u"Found a host,\nbut the beckoner is a blood-addled hunter, and thus an adversary.", {u"Anfitrión encontrado,\npero es un cazador ebrio de sangre y, por tanto, un adversario.", u"Hôte trouvé,\nmais c'est un chasseur ivre de sang, et donc un adversaire.", u"Gastgeber gefunden,\ndoch der Rufende ist ein blutberauschter Jäger und somit ein Gegner.", u"Ospitante trovato,\nma è un cacciatore ebbro di sangue, e dunque un avversario.", u"Anfitrião encontrado,\nmas ele é um caçador embriagado de sangue e, portanto, um adversário.", u"Хозяин мира найден,\nно призвавший — одурманенный кровью охотник, а значит, противник."}},
|
||||
{u"Ring the Hunt Bell to call\nother players into your game.", {u"Toca la Campana de cacería para llamar\na otros jugadores a tu partida.", u"Sonnez la Cloche de chasse pour appeler\nd'autres joueurs dans votre partie.", u"Läute die Jagdglocke, um andere\nSpieler in dein Spiel zu rufen.", u"Suona la Campana della caccia per\nchiamare altri giocatori nella partita.", u"Toque o Sino da Caçada para chamar\noutros jogadores ao seu jogo.", u"Позвоните в Колокол охоты, чтобы\nпризвать других игроков в свою игру."}},
|
||||
{u"Use the Parting Blank to end co-op.", {u"Usa el Cartucho de despedida para terminar el cooperativo.", u"La Cartouche d'adieu met fin à la coopération.", u"Beende das Koop mit der Abschiedspatrone.", u"La Cartuccia del commiato chiude la cooperativa.", u"Use o Cartucho de Despedida para encerrar o cooperativo.", u"Совместную игру завершает Прощальный патрон."}},
|
||||
{u"Bloodlust Bell", {u"Campana sanguinaria", u"Cloche sanguinaire", u"Blutrauschglocke", u"Campana sanguinaria", u"Sino Sanguinário", u"Колокол кровожадности"}},
|
||||
{u"Parting Blank", {u"Cartucho de despedida", u"Cartouche d'adieu", u"Abschiedspatrone", u"Cartuccia del commiato", u"Cartucho de Despedida", u"Прощальный патрон"}},
|
||||
{u"End co-op", {u"Terminar cooperativo", u"Fin de la coopération", u"Koop beenden", u"Termina cooperativa", u"Encerrar cooperativo", u"Завершить совместную игру"}},
|
||||
{u"End co-op and go home", {u"Terminar cooperativo y volver", u"Fin de la coopération et retour", u"Koop beenden und heimkehren", u"Termina cooperativa e torna a casa", u"Encerrar cooperativo e voltar", u"Завершить и вернуться домой"}},
|
||||
{u"Quick search: find a host and join their game.", {u"Búsqueda rápida de un anfitrión al que unirte.", u"Recherche rapide : trouver un hôte et rejoindre sa partie.", u"Schnellsuche: Gastgeber finden und seinem Spiel beitreten.", u"Ricerca rapida: trova un ospitante e unisciti.", u"Busca rápida: ache um anfitrião e entre no jogo dele.", u"Быстрый поиск: найти хозяина мира и войти в его игру."}},
|
||||
{u"Quick search: find a world to invade.", {u"Búsqueda rápida: encuentra un mundo que invadir.", u"Recherche rapide : trouver un monde à envahir.", u"Schnellsuche: Welt zum Eindringen finden.", u"Ricerca rapida: trova un mondo da invadere.", u"Busca rápida: encontre um mundo para invadir.", u"Быстрый поиск: найти мир для вторжения."}},
|
||||
{u"Stop calling other players.", {u"Dejar de llamar a otros jugadores.", u"Cesser d'appeler d'autres joueurs.", u"Keine Spieler mehr rufen.", u"Smetti di chiamare altri giocatori.", u"Parar de chamar outros jogadores.", u"Перестать звать других игроков."}},
|
||||
{u"Stop looking for a host.", {u"Dejar de buscar un anfitrión.", u"Cesser de chercher un hôte.", u"Suche nach Gastgeber beenden.", u"Smetti di cercare un ospitante.", u"Parar de procurar um anfitrião.", u"Прекратить поиск хозяина мира."}},
|
||||
{u"Stop looking for a world to invade.", {u"Dejar de buscar un mundo que invadir.", u"Cesser de chercher un monde à envahir.", u"Suche nach einer Welt zum Eindringen beenden.", u"Smetti di cercare un mondo da invadere.", u"Parar de procurar um mundo para invadir.", u"Прекратить поиск мира для вторжения."}},
|
||||
{u"Find a host and join their game?", {u"¿Buscar un anfitrión y unirte a su partida?", u"Trouver un hôte et rejoindre sa partie ?", u"Gastgeber finden und seinem Spiel beitreten?", u"Cercare un ospitante e unirsi a lui?", u"Encontrar um anfitrião e entrar no jogo dele?", u"Найти хозяина мира и войти в его игру?"}},
|
||||
{u"Find a world to invade?", {u"¿Buscar un mundo que invadir?", u"Trouver un monde à envahir ?", u"Welt zum Eindringen finden?", u"Trovare un mondo da invadere?", u"Encontrar um mundo para invadir?", u"Найти мир для вторжения?"}},
|
||||
{u"Need help? Ring the Hunt Bell.", {u"¿Necesitas ayuda? Toca la Campana de cacería.", u"Besoin d'aide ? Sonnez la Cloche de chasse.", u"Hilfe nötig? Läute die Jagdglocke.", u"Serve aiuto? Suona la Campana della caccia.", u"Precisa de ajuda? Toque o Sino da Caçada.", u"Нужна помощь? Позвоните в Колокол охоты."}},
|
||||
{u"Call other players into your game\nwith the Hunt Bell.", {u"Llama a otros jugadores a tu partida\ncon la Campana de cacería.", u"Appelez d'autres joueurs dans votre partie\navec la Cloche de chasse.", u"Rufe mit der Jagdglocke andere\nSpieler in dein Spiel.", u"Chiama altri giocatori nella tua partita\ncon la Campana della caccia.", u"Chame outros jogadores ao seu jogo\ncom o Sino da Caçada.", u"Зовите других игроков в свою игру\nс помощью Колокола охоты."}},
|
||||
{u"Join another player's game\nwith the Kin Bell.", {u"Únete a la partida de otro jugador\ncon la Campana fraterna.", u"Rejoignez la partie d'un autre joueur\navec la Cloche fraternelle.", u"Tritt mit der Sippenglocke dem Spiel\neines anderen Spielers bei.", u"Unisciti a un altro giocatore\ncon la Campana fraterna.", u"Entre no jogo de outro jogador\ncom o Sino Fraterno.", u"Входите в игру других игроков\nс помощью Колокола родства."}},
|
||||
{u"You caught up with <?hostName?>.", {u"Has alcanzado a <?hostName?>.", u"Vous avez rattrapé <?hostName?>.", u"Du hast <?hostName?> eingeholt.", u"Hai raggiunto <?hostName?>.", u"Você alcançou <?hostName?>.", u"Вы догнали игрока <?hostName?>."}},
|
||||
{u"<?joinName?> caught up with you.", {u"<?joinName?> te ha alcanzado.", u"<?joinName?> vous a rattrapé.", u"<?joinName?> hat dich eingeholt.", u"<?joinName?> ti ha raggiunto.", u"<?joinName?> alcançou você.", u"Игрок <?joinName?> догнал вас."}},
|
||||
{u"You are in the host's Hunter's Dream.", {u"Estás en el Sueño del cazador del anfitrión.", u"Vous êtes dans le Rêve du chasseur de l'hôte.", u"Du bist im Traum des Jägers des Gastgebers.", u"Sei nel Sogno del cacciatore dell'ospitante.", u"Você está no Sonho do Caçador do anfitrião.", u"Вы во Сне охотника хозяина мира."}},
|
||||
{u"Hunt Bell", {u"Campana de cacería", u"Cloche de chasse", u"Jagdglocke", u"Campana della caccia", u"Sino da Caçada", u"Колокол охоты"}},
|
||||
{u"Ring to call other players into your game.\nCosts no Insight.", {u"Tócala para llamar a otros jugadores a tu partida.\nNo cuesta Lucidez.", u"Sonnez-la pour appeler d'autres joueurs.\nNe coûte aucune Intuition.", u"Läuten, um andere Spieler in dein Spiel zu rufen.\nKostet keine Einsicht.", u"Suonala per chiamare altri giocatori nella partita.\nNon consuma Intuizione.", u"Toque para chamar outros jogadores ao seu jogo.\nNão consome Discernimento.", u"Призывает других игроков в вашу игру.\nНе расходует Озарение."}},
|
||||
{u"An old bell carried by the hunters of the\nworkshop, its toll heard across every world.\n\nRing it, and hunters who answer with a\nKin Bell may join your hunt. Together you\nwalk the night, through every lamp,\ndeath and dream, until the hunt is done.", {u"Vieja campana que portaban los cazadores del\ntaller, cuyo tañido se oye en todos los mundos.\n\nTócala, y los cazadores que respondan con una\nCampana fraterna podrán unirse a tu cacería.\nJuntos recorréis la noche, entre faroles,\nmuertes y sueños, hasta el fin de la cacería.", u"Vieille cloche des chasseurs de l'atelier,\ndont le glas résonne à travers les mondes.\n\nSonnez-la, et ceux qui répondent d'une\nCloche fraternelle rejoindront votre chasse.\nEnsemble, vous arpentez la nuit, de lanterne\nen mort et en rêve, jusqu'au bout de la chasse.", u"Eine alte Glocke der Jäger der Werkstatt,\nderen Schlag in allen Welten erklingt.\n\nLäute sie, und Jäger, die mit einer\nSippenglocke antworten, schließen sich\ndeiner Jagd an. Gemeinsam durchwandert\nihr die Nacht, durch jede Laterne, jeden\nTod und Traum, bis die Jagd vollendet ist.", u"Antica campana dei cacciatori della bottega,\nil cui rintocco si ode in ogni mondo.\n\nSuonala, e i cacciatori che rispondono con\nuna Campana fraterna si uniranno alla tua\ncaccia. Insieme attraverserete la notte,\nogni lanterna, morte e sogno, fino alla fine.", u"Um velho sino levado pelos caçadores da\noficina, cujo dobre ecoa em todos os mundos.\n\nToque-o, e caçadores que responderem com um\nSino Fraterno poderão se unir à sua caçada.\nJuntos, vocês percorrem a noite, por lampião,\nmorte e sonho, até que a caçada termine.", u"Старый колокол охотников мастерской;\nего звон слышен во всех мирах.\n\nПозвоните в него, и охотники, что ответят\nКолоколом родства, разделят вашу охоту.\nВместе вы пройдёте ночь — через каждый\nфонарь, смерть и сон, пока охота не кончится."}},
|
||||
{u"Kin Bell", {u"Campana fraterna", u"Cloche fraternelle", u"Sippenglocke", u"Campana fraterna", u"Sino Fraterno", u"Колокол родства"}},
|
||||
{u"Ring to join another players game.\nCosts no Insight.", {u"Tócala para unirte a la partida de otro jugador.\nNo cuesta Lucidez.", u"Sonnez-la pour rejoindre la partie d'un autre.\nNe coûte aucune Intuition.", u"Läuten, um dem Spiel eines anderen Spielers beizutreten.\nKostet keine Einsicht.", u"Suonala per unirti alla partita di un altro.\nNon consuma Intuizione.", u"Toque para entrar no jogo de outro jogador.\nNão consome Discernimento.", u"Позволяет войти в игру другого игрока.\nНе расходует Озарение."}},
|
||||
{u"A small bell of the choir, its chime\nfaint, but true.\n\nHunters who hear a Hunt Bell may answer\nwith it, crossing into another world to\nstand at the side of kin. What is slain\nthere is slain for both.", {u"Pequeña campana del coro, de tañido\ndébil, pero fiel.\n\nLos cazadores que oyen una Campana de cacería\npueden responder con ella y cruzar a otro mundo\npara luchar junto a los suyos. Lo que allí\ncae, cae para ambos.", u"Petite cloche du Chœur, au tintement\nfaible, mais sincère.\n\nLes chasseurs qui entendent une Cloche de\nchasse peuvent y répondre et passer dans\nun autre monde, aux côtés des leurs. Ce qui\ny est abattu l'est pour tous deux.", u"Eine kleine Glocke des Chors, ihr Klang\nleise, doch aufrichtig.\n\nJäger, die eine Jagdglocke hören, können\nmit ihr antworten, in eine andere Welt\nhinübertreten und der Sippe beistehen.\nWas dort erschlagen wird, ist für\nbeide erschlagen.", u"Piccola campana del Coro, dal rintocco\nflebile, ma sincero.\n\nChi ode una Campana della caccia può\nrispondere con essa, varcando un altro\nmondo per stare al fianco dei fratelli.\nCiò che là viene ucciso, muore per entrambi.", u"Um pequeno sino do coro, de toque\nfraco, porém verdadeiro.\n\nCaçadores que ouvem um Sino da Caçada podem\nrespondê-lo, cruzando para outro mundo para\nficar ao lado dos seus. O que ali é abatido\né abatido para ambos.", u"Малый колокол хора, чей звон\nслаб, но верен.\n\nУслышав Колокол охоты, охотник может\nответить им и перейти в иной мир, чтобы\nвстать плечом к плечу с родными. Всё, что\nпало там, пало для обоих."}},
|
||||
{u"Ring to invade another players world.\nCosts no Insight.", {u"Tócala para invadir el mundo de otro jugador.\nNo cuesta Lucidez.", u"Sonnez-la pour envahir le monde d'un autre.\nNe coûte aucune Intuition.", u"Läuten, um in die Welt eines anderen Spielers einzudringen.\nKostet keine Einsicht.", u"Suonala per invadere il mondo di un altro.\nNon consuma Intuizione.", u"Toque para invadir o mundo de outro jogador.\nNão consome Discernimento.", u"Позволяет вторгнуться в мир другого игрока.\nНе расходует Озарение."}},
|
||||
{u"A bell that sounds only for the blood-drunk.\n\nNo maiden need ring for its toll to be\nheard: every hunter of the night is prey,\nalone or in company, wherever they walk.\n\nThe hunt for hunters never ends.", {u"Campana que solo tañe para los ebrios de sangre.\n\nNo hace falta doncella alguna para que se oiga\nsu tañido: todo cazador de la noche es presa,\nsolo o en compañía, allá donde vaya.\n\nLa cacería de cazadores no termina jamás.", u"Cloche qui ne sonne que pour les ivres de sang.\n\nNulle sonneuse n'est requise pour que son\nglas soit entendu : tout chasseur de la nuit\nest une proie, seul ou accompagné, où qu'il aille.\n\nLa chasse aux chasseurs n'a pas de fin.", u"Eine Glocke, die nur für die Blutberauschten\nerklingt.\n\nKeine Glockenfrau muss sie läuten, damit\nihr Schlag gehört wird: Jeder Jäger der\nNacht ist Beute, allein oder in Begleitung,\nwohin er auch geht.\n\nDie Jagd auf Jäger endet niemals.", u"Campana che suona solo per gli ebbri di sangue.\n\nNessuna fanciulla deve suonarla perché il\nsuo rintocco sia udito: ogni cacciatore\nnella notte è una preda, solo o in compagnia,\novunque egli cammini.\n\nLa caccia ai cacciatori non ha mai fine.", u"Um sino que soa apenas para os ébrios de sangue.\n\nNenhuma donzela precisa tocá-lo para que seu\ndobre seja ouvido: todo caçador da noite é presa,\nsozinho ou acompanhado, onde quer que ande.\n\nA caçada aos caçadores nunca termina.", u"Колокол, звучащий лишь для опьянённых кровью.\n\nЧтобы его звон услышали, не нужна дева\nс колоколом: любой охотник ночи — добыча,\nодин или в компании, где бы он ни был.\n\nОхота на охотников не кончается никогда."}},
|
||||
{u"Use to end co-op.\nGuests go home; the host sends everyone home.", {u"Úsalo para terminar el cooperativo.\nInvitado: vuelve a casa. Anfitrión: todos vuelven.", u"Met fin à la coopération.\nInvité : retour chez soi. Hôte : tous renvoyés.", u"Benutzen, um das Koop zu beenden.\nGäste kehren heim; der Gastgeber schickt alle heim.", u"Usala per terminare la cooperativa.\nOspite: torni a casa. Ospitante: tutti a casa.", u"Use para encerrar o cooperativo.\nConvidados voltam; o anfitrião devolve todos.", u"Завершает совместную игру.\nГость уходит домой, хозяин — отправляет всех."}},
|
||||
{u"A blank of silent powder.\n\nIts inaudible burst breaks the resonance\nbinding hunters of different worlds, and\neach returns to their own night.", {u"Cartucho de pólvora silenciosa.\n\nSu estallido inaudible rompe la resonancia\nque une a cazadores de mundos distintos, y\ncada uno regresa a su propia noche.", u"Cartouche de poudre silencieuse.\n\nSa détonation inaudible brise la résonance\nqui lie les chasseurs de mondes différents,\net chacun regagne sa propre nuit.", u"Eine Patrone aus lautlosem Pulver.\n\nIhr unhörbarer Knall bricht die Resonanz,\ndie Jäger verschiedener Welten verbindet,\nund jeder kehrt in seine eigene Nacht\nzurück.", u"Cartuccia di polvere silenziosa.\n\nIl suo scoppio impercettibile spezza la\nrisonanza che lega cacciatori di mondi\ndiversi, e ognuno torna alla propria notte.", u"Um cartucho de pólvora silenciosa.\n\nSua explosão inaudível rompe a ressonância\nque une caçadores de mundos diferentes, e\ncada um retorna à sua própria noite.", u"Патрон с беззвучным порохом.\n\nЕго неслышный выстрел рвёт резонанс,\nсвязующий охотников разных миров, и\nкаждый возвращается в свою ночь."}},
|
||||
{u"No longer used. Use the Hunt Bell.", {u"Ya no se usa. Usa la Campana de cacería.", u"Obsolète. Utilisez la Cloche de chasse.", u"Nicht mehr in Gebrauch. Nutze die Jagdglocke.", u"Non più in uso. Usa la Campana della caccia.", u"Fora de uso. Use o Sino da Caçada.", u"Устарел. Используйте Колокол охоты."}},
|
||||
{u"No longer used. Use the Kin Bell.", {u"Ya no se usa. Usa la Campana fraterna.", u"Obsolète. Utilisez la Cloche fraternelle.", u"Nicht mehr in Gebrauch. Nutze die Sippenglocke.", u"Non più in uso. Usa la Campana fraterna.", u"Fora de uso. Use o Sino Fraterno.", u"Устарел. Используйте Колокол родства."}},
|
||||
{u"No longer used. Use the Bloodlust Bell.", {u"Ya no se usa. Usa la Campana sanguinaria.", u"Obsolète. Utilisez la Cloche sanguinaire.", u"Nicht mehr in Gebrauch. Nutze die Blutrauschglocke.", u"Non più in uso. Usa la Campana sanguinaria.", u"Fora de uso. Use o Sino Sanguinário.", u"Устарел. Используйте Колокол кровожадности."}},
|
||||
{u"No longer used. Use the Parting Blank.", {u"Ya no se usa. Usa el Cartucho de despedida.", u"Obsolète. Utilisez la Cartouche d'adieu.", u"Nicht mehr in Gebrauch. Nutze die Abschiedspatrone.", u"Non più in uso. Usa la Cartuccia del commiato.", u"Fora de uso. Use o Cartucho de Despedida.", u"Устарел. Используйте Прощальный патрон."}},
|
||||
{u"Duel Bell", {u"Campana de duelo", u"Cloche de duel", u"Duellglocke", u"Campana del duello", u"Sino do Duelo", u"Колокол дуэли"}},
|
||||
{u"Ring in co-op to turn on PvP between players.\nRing again to turn it off.", {u"Tócala en cooperativo para activar el PvP.\nVuelve a tocarla para desactivarlo.", u"En coopération, active le PvP entre joueurs.\nSonnez-la à nouveau pour le désactiver.", u"Im Koop läuten, um PvP zwischen Spielern zu aktivieren.\nErneut läuten, um es zu deaktivieren.", u"Suonala in cooperativa per attivare il PvP.\nSuonala di nuovo per disattivarlo.", u"No cooperativo, toque para ativar o PvP.\nToque de novo para desativá-lo.", u"В совместной игре включает PvP между игроками.\nПовторный звон выключает его."}},
|
||||
{u"A cracked bell with a sour, discordant chime.\n\nRing it while hunting together, and the\nresonance binding the hunters turns to\nrivalry: they may strike one another as foes.\n\nRing it again to restore the old bond.\nOnly the beckoner decides; a cooperator\nwho rings it merely asks.", {u"Campana agrietada de tañido agrio y discordante.\n\nAl tocarla durante una cacería conjunta, la\nresonancia que une a los cazadores se torna\nrivalidad: podrán herirse como enemigos.\n\nVuelve a tocarla para restaurar el vínculo.\nSolo el anfitrión decide; el cooperador\nque la toca se limita a pedirlo.", u"Cloche fêlée au tintement aigre et discordant.\n\nSonnez-la en pleine chasse commune, et la\nrésonance qui lie les chasseurs vire à la\nrivalité : ils peuvent se frapper en ennemis.\n\nSonnez-la à nouveau pour rétablir le lien.\nSeul l'hôte décide ; le coopérateur\nqui la sonne ne fait que le demander.", u"Eine gesprungene Glocke, ihr Klang herb\nund misstönend.\n\nLäute sie auf gemeinsamer Jagd, und die\nResonanz, die die Jäger verbindet, wird zu\nRivalität: Sie dürfen einander als Feinde\nschlagen.\n\nLäute sie erneut, um das alte Band\nwiederherzustellen. Nur der Rufende\nentscheidet; ein Mitstreiter, der sie\nläutet, bittet lediglich darum.", u"Campana incrinata dal rintocco aspro e stonato.\n\nSuonala durante una caccia comune, e la\nrisonanza che lega i cacciatori si muta in\nrivalità: potranno colpirsi come nemici.\n\nSuonala di nuovo per ripristinare il legame.\nSolo l'ospitante decide; un cooperatore\nche la suona si limita a chiederlo.", u"Um sino rachado, de toque azedo e dissonante.\n\nToque-o durante uma caçada conjunta, e a\nressonância que une os caçadores vira\nrivalidade: podem golpear uns aos outros.\n\nToque-o de novo para restaurar o antigo laço.\nSó o anfitrião decide; o cooperador\nque o toca apenas pede.", u"Треснувший колокол с резким, нестройным звоном.\n\nЕсли позвонить в него на совместной охоте,\nрезонанс, что связывает охотников, обратится\nсоперничеством: они смогут разить друг друга.\n\nПозвоните вновь, чтобы вернуть прежние узы.\nРешает лишь призвавший; союзник,\nпозвонив в него, лишь просит об этом."}},
|
||||
{u"You respawned at the host's lamp.", {u"Has reaparecido en el farol del anfitrión.", u"Vous êtes réapparu à la lanterne de l'hôte.", u"Du bist an der Laterne des Gastgebers wiedererwacht.", u"Sei rinato alla lanterna dell'ospitante.", u"Você renasceu no lampião do anfitrião.", u"Вы возродились у фонаря хозяина мира."}},
|
||||
{u"You respawned next to the host.", {u"Has reaparecido junto al anfitrión.", u"Vous êtes réapparu près de l'hôte.", u"Du bist neben dem Gastgeber wiedererwacht.", u"Sei rinato accanto all'ospitante.", u"Você renasceu ao lado do anfitrião.", u"Вы возродились рядом с хозяином мира."}},
|
||||
{u"You respawned at the lamp.", {u"Has reaparecido en el farol.", u"Vous êtes réapparu à la lanterne.", u"Du bist an der Laterne wiedererwacht.", u"Sei rinato alla lanterna.", u"Você renasceu no lampião.", u"Вы возродились у фонаря."}},
|
||||
{u"You respawned.", {u"Has reaparecido.", u"Vous êtes réapparu.", u"Du bist wiedererwacht.", u"Sei rinato.", u"Você renasceu.", u"Вы возродились."}},
|
||||
{u"The host died.\nThey will respawn at the lamp.", {u"El anfitrión ha muerto.\nReaparecerá en el farol.", u"L'hôte est mort.\nIl va réapparaître à la lanterne.", u"Der Gastgeber ist gestorben.\nEr erwacht an der Laterne wieder.", u"L'ospitante è morto.\nRinascerà alla lanterna.", u"O anfitrião morreu.\nEle vai renascer no lampião.", u"Хозяин мира погиб.\nОн возродится у фонаря."}},
|
||||
{u"Boss reward added to your items.", {u"Recompensa del jefe añadida a tus objetos.", u"Récompense du boss ajoutée à vos objets.", u"Bossbelohnung zu deinen Gegenständen hinzugefügt.", u"Ricompensa del boss aggiunta agli oggetti.", u"Recompensa do chefe adicionada aos seus itens.", u"Награда за босса добавлена в инвентарь."}},
|
||||
{u"Shared progress saved:\nthe host's progress is now in your world.", {u"Progreso compartido guardado:\nel progreso del anfitrión ya está en tu mundo.", u"Progression partagée sauvegardée :\ncelle de l'hôte est dans votre monde.", u"Gemeinsamer Fortschritt gespeichert:\nDer Fortschritt des Gastgebers ist nun in deiner Welt.", u"Progressi condivisi salvati:\nquelli dell'ospitante ora sono nel tuo mondo.", u"Progresso compartilhado salvo:\no progresso do anfitrião agora está no seu mundo.", u"Общий прогресс сохранён:\nпрогресс хозяина теперь в вашем мире."}},
|
||||
{u"Shared progress saved.\nRest at a lamp to see all of it.", {u"Progreso compartido guardado.\nDescansa en un farol para verlo todo.", u"Progression partagée sauvegardée.\nReposez-vous à une lanterne pour la voir.", u"Gemeinsamer Fortschritt gespeichert.\nRaste an einer Laterne, um alles zu sehen.", u"Progressi condivisi salvati.\nRiposa a una lanterna per vederli tutti.", u"Progresso compartilhado salvo.\nDescanse num lampião para ver tudo.", u"Общий прогресс сохранён.\nОтдохните у фонаря, чтобы увидеть всё."}},
|
||||
{u"BBSC items added:", {u"Objetos de BBSC añadidos:", u"Objets BBSC ajoutés :", u"BBSC-Gegenstände erhalten:", u"Oggetti BBSC aggiunti:", u"Itens do BBSC adicionados:", u"Добавлены предметы BBSC:"}},
|
||||
{u"PvP is ON.\nPlayers can hurt each other.", {u"PvP ACTIVADO.\nLos jugadores pueden herirse entre sí.", u"PvP ACTIVÉ.\nLes joueurs peuvent se blesser.", u"PvP ist AN.\nSpieler können einander verletzen.", u"PvP ATTIVO.\nI giocatori possono ferirsi a vicenda.", u"PvP ATIVADO.\nOs jogadores podem se ferir.", u"PvP ВКЛЮЧЕНО.\nИгроки могут ранить друг друга."}},
|
||||
{u"PvP is OFF.", {u"PvP DESACTIVADO.", u"PvP DÉSACTIVÉ.", u"PvP ist AUS.", u"PvP DISATTIVATO.", u"PvP DESATIVADO.", u"PvP ВЫКЛЮЧЕНО."}},
|
||||
{u"PvP needs another player in your game.", {u"El PvP requiere otro jugador en tu partida.", u"Le PvP requiert un autre joueur dans la partie.", u"PvP braucht einen weiteren Spieler in deinem Spiel.", u"Il PvP richiede un altro giocatore in partita.", u"O PvP precisa de outro jogador no seu jogo.", u"Для PvP нужен ещё один игрок в вашей игре."}},
|
||||
{u"Your own world was put back\n(the game had closed while you were a guest).\nRest at a lamp or reload to see it.", {u"Se ha restaurado tu propio mundo\n(el juego se cerró mientras eras invitado).\nDescansa en un farol o recarga para verlo.", u"Votre monde a été restauré\n(le jeu s'est fermé pendant que vous étiez invité).\nReposez-vous à une lanterne ou rechargez.", u"Deine eigene Welt wurde wiederhergestellt\n(das Spiel wurde geschlossen, während du Gast warst).\nRaste an einer Laterne oder lade neu, um sie zu sehen.", u"Il tuo mondo è stato ripristinato\n(il gioco si era chiuso mentre eri ospite).\nRiposa a una lanterna o ricarica per vederlo.", u"Seu próprio mundo foi restaurado\n(o jogo fechou enquanto você era convidado).\nDescanse num lampião ou recarregue para vê-lo.", u"Ваш собственный мир восстановлен\n(игра закрылась, пока вы были гостем).\nОтдохните у фонаря или перезапустите игру."}},
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,60 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace Core::Bloodborne {
|
||||
|
||||
std::optional<std::string> GetSeamlessHostPlacementHeader();
|
||||
bool SetSeamlessHostPlacementHeader(std::string_view value);
|
||||
void ClearSeamlessHostPlacementHeader();
|
||||
// BBSC connect-first cross-map summon: the host map a pending sign should advertise, if any.
|
||||
std::optional<std::uint32_t> GetBbscConnectFirstAreaId();
|
||||
void TraceMatching2LeaveRoom(std::uintptr_t return_address, std::uint64_t room_id);
|
||||
// BBSC warp together: a "the host is in this map" packet arrived from the signaling peer. Called
|
||||
// on the signaling receive thread, so it only records the message; TickBbscFollowHost acts on it.
|
||||
void OnBbscHostFollowPacket(std::uint32_t packed_map, float x, float y, float z, float heading,
|
||||
std::uint32_t sequence, std::int32_t lamp_point);
|
||||
// BBSC world state: one chunk of the host's event flags for its map block arrived (a whole
|
||||
// SignalingBbscWorldFlags packet). Signaling receive thread; only stores it.
|
||||
void OnBbscWorldFlagsPacket(const void* packet, std::uint32_t size);
|
||||
// BBSC life state: another player says whether it is dead (spectating) or alive. Relay receive
|
||||
// thread; only stores it.
|
||||
void OnBbscLifePacket(std::uint32_t player, std::uint8_t dead, std::uint32_t sequence);
|
||||
// BBSC scaling: the host's five scaling percentages (enemy HP, boss HP, damage, enemy stagger, boss
|
||||
// stagger). Relay receive thread; only stores them.
|
||||
void OnBbscScalePacket(const std::uint16_t (&percent)[5]);
|
||||
// BBSC PvP (Duel Bell): kind 0 = the host's PvP state, kind 1 = a guest asks the host for `on`.
|
||||
// Relay receive thread; only stores it.
|
||||
void OnBbscPvpPacket(std::uint8_t kind, std::uint8_t on);
|
||||
// BBSC relay sessions (mod): a hosting player's follow packet (relay thread; only stored), the host
|
||||
// a guest takes packets from (0 = none known yet), and the session state as of the last game tick.
|
||||
// BBSC shared progress: the host's boss-defeat flags that are on (relay "SHAD" 0x26). Relay thread.
|
||||
void OnBbscProgressPacket(const std::uint32_t* flags, std::size_t count);
|
||||
// BBSC boss line: the boss on the host's boss bar (name id, -1 none; relay "SHAD" 0x27).
|
||||
void OnBbscHostBossPacket(std::int32_t boss);
|
||||
void NoteBbscRelayHost(std::uint32_t sender, std::uint32_t packed_map, float x, float y, float z);
|
||||
std::uint32_t GetBbscRelayHost();
|
||||
std::int32_t GetBbscRelaySessionState();
|
||||
void InstallSeamlessCoopPatches();
|
||||
void InstallReverseEngineeringTrace();
|
||||
|
||||
// BBSC Discord status: what the game is doing, gathered once a second on the game tick.
|
||||
struct BbscPresence {
|
||||
bool in_game = false;
|
||||
std::string area; // "Central Yharnam", "Chalice Dungeon", ...
|
||||
std::string boss; // boss bar name during a boss fight, else empty
|
||||
int role = 0; // 0 solo, 1 host, 2 guest
|
||||
int hunters = 1; // players in the session
|
||||
int max_hunters = 3;
|
||||
std::int64_t boss_since_s = 0; // unix time the boss bar appeared
|
||||
};
|
||||
BbscPresence GetBbscPresence();
|
||||
|
||||
} // namespace Core::Bloodborne
|
||||
@@ -0,0 +1,96 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2024 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <span>
|
||||
|
||||
#include "common/types.h"
|
||||
|
||||
// ============================================================================
|
||||
// Windows static guest red-zone protection
|
||||
// ============================================================================
|
||||
|
||||
enum class WindowsGuestRedZoneProtectionMode : u32 {
|
||||
Disabled,
|
||||
StaticPatching,
|
||||
};
|
||||
|
||||
namespace Core::WindowsGuestRedZoneProtection {
|
||||
|
||||
void SetActiveMode(WindowsGuestRedZoneProtectionMode mode) noexcept;
|
||||
WindowsGuestRedZoneProtectionMode GetActiveMode() noexcept;
|
||||
bool IsStaticPatchingEnabled() noexcept;
|
||||
|
||||
} // namespace Core::WindowsGuestRedZoneProtection
|
||||
|
||||
// ============================================================================
|
||||
// End Windows static guest red-zone protection
|
||||
// ============================================================================
|
||||
|
||||
namespace Core {
|
||||
|
||||
struct alignas(16) GuestRegisterSnapshot {
|
||||
u64 rax;
|
||||
u64 rbx;
|
||||
u64 rcx;
|
||||
u64 rdx;
|
||||
u64 rsi;
|
||||
u64 rdi;
|
||||
u64 rbp;
|
||||
u64 rsp;
|
||||
u64 r8;
|
||||
u64 r9;
|
||||
u64 r10;
|
||||
u64 r11;
|
||||
u64 r12;
|
||||
u64 r13;
|
||||
u64 r14;
|
||||
u64 r15;
|
||||
u64 rflags;
|
||||
u32 mxcsr;
|
||||
alignas(16) std::array<std::array<u8, 32>, 16> ymm;
|
||||
};
|
||||
|
||||
using GuestCodeHook = void PS4_SYSV_ABI (*)(u64 tag, const GuestRegisterSnapshot* registers);
|
||||
|
||||
// Windows static guest red-zone protection
|
||||
struct RedZonePatchResult {
|
||||
u64 function_count{};
|
||||
u64 instruction_count{};
|
||||
u64 red_zone_function_count{};
|
||||
u64 memory_instruction_count{};
|
||||
u64 short_memory_instruction_count{};
|
||||
u64 patched_memory_instruction_count{};
|
||||
u64 stack_dependent_memory_instruction_count{};
|
||||
u64 control_flow_memory_instruction_count{};
|
||||
u64 unrelocatable_memory_instruction_count{};
|
||||
u64 indirect_red_zone_function_count{};
|
||||
u64 cpu_patch_instruction_count{};
|
||||
u64 patched_cpu_patch_instruction_count{};
|
||||
u64 unsupported_cpu_patch_instruction_count{};
|
||||
};
|
||||
|
||||
/// Registers a module for patching, providing an area to generate trampoline code.
|
||||
void RegisterPatchModule(void* module_ptr, u64 module_size, void* trampoline_area_ptr,
|
||||
u64 trampoline_area_size);
|
||||
|
||||
/// Installs a byte-verified observer while preserving GPR, flags, MXCSR, and YMM state.
|
||||
/// The copied instruction span must contain whole instructions and must not use relative
|
||||
/// addressing.
|
||||
// skip_flag (optional): if the hook sets *skip_flag to non-zero, the trampoline clears it and
|
||||
// returns straight to the caller instead of running the hooked function (function entry only).
|
||||
bool InstallGuestCodeHook(void* address, std::span<const u8> expected_instructions, u64 tag,
|
||||
GuestCodeHook hook,
|
||||
const u8* skip_flag = nullptr);
|
||||
|
||||
/// Applies CPU patches that need to be done before beginning executions.
|
||||
void PrePatchInstructions(u64 segment_addr, u64 segment_size);
|
||||
|
||||
// Windows static guest red-zone protection
|
||||
/// Keeps Windows exception dispatch outside live guest red zones at faultable memory accesses.
|
||||
RedZonePatchResult PatchRedZoneMemoryInstructions(u64 segment_addr, u64 segment_size,
|
||||
std::span<const uintptr_t> function_starts);
|
||||
|
||||
} // namespace Core
|
||||
@@ -0,0 +1,16 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace Core::Bbsc {
|
||||
|
||||
// What the guest is loading into, for the loading screen's line.
|
||||
enum class LoadingScreenKind { Summon, Follow };
|
||||
|
||||
// BBSC's own loading screen, drawn by the ImGui overlay over the black load cover while a guest's
|
||||
// summon / cross-map / follow load runs (the game shows nothing there). Any thread.
|
||||
void ShowLoadingScreen(LoadingScreenKind kind);
|
||||
void HideLoadingScreen();
|
||||
|
||||
} // namespace Core::Bbsc
|
||||
Loaded 100 of 431 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user