1 Commits
Author SHA1 Message Date
CLS_AMG 4f11faf88b BBSC 0.0.2 2026-10-20 21:49:09 +03:00
33 changed files with 9363 additions and 429 deletions

No files matched your search

+3 -1
View File
@@ -20,7 +20,9 @@ SPECIAL THANKS
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
the game server and ss.info groundwork BBSC's servers grew from,
and the fixed Chalice Dungeon data (NoDreamForTheHunterBB, published by Mob)
https://github.com/Mob100percent/NoDreamForTheHunterBB
BUILT ON
shadPS4 - the PS4 emulator (shadPS4 team and contributors, GPL-2.0)
+3 -2
View File
@@ -11,12 +11,13 @@ Support BBSC:
WORKSHOP
--------
(no one yet)
aboood888848
Prohorov Ilja
HUNTER
------
(no one yet)
.ζ͜长𐌏𐌕𐌏ᚱ𐌆
LAMPLIGHT
+14
View File
@@ -0,0 +1,14 @@
BBSC's BBport build - source
============================
emulator\bb-probe.exe is built from:
https://github.com/Supermedo/bloodborne_pc commit e5d04e2 ("Version 1.7.2")
plus bbport-1.7.2-bbsc.patch in this folder (git apply). Build: bash build.sh in MSYS2 CLANG64
(see BBport's README). License: GPL-2.0 (emulator\LICENSE-BBport.txt); the shadPS4/shadp2p parts
are GPL-2.0-or-later.
Changes (gpu/shadps4 = BBport's copy of shadp2p):
- core/libraries/network/p2p_sockets.cpp, sockets.h: relay fallback through the BBSC server
(UDP 3478) when no direct reply arrives within 2 s
- core/libraries/np/np_signaling/*: rejoin fix (re-activation checks the peer, new peer
connection id restarts the handshake)
- core/libraries/np/np_utility.cpp/.h (new): sceNpLookupNpId answered from the online id
- np_matching2: peer endpoints for the relay; bbnet/bbnet_glue.cpp registers NpUtility
File diff suppressed because it is too large. Load diff
+3
View File
@@ -958,6 +958,9 @@ set(CORE src/core/aerolib/stubs.cpp
if (ARCHITECTURE STREQUAL "x86_64")
set(CORE ${CORE}
src/core/bbsc_persistence.cpp
src/core/bbsc_relay_client.cpp
src/core/bbsc_relay_client.h
mod/bbsc_discord.cpp
src/core/bbsc_cinematic.cpp
src/core/bbsc_cinematic.h
src/core/bbsc_persistence.h
+12 -10
View File
@@ -41,6 +41,8 @@
namespace BbscMod {
u64 EbootAddress(); // bbsc_mod.cpp
bool ReadSlotSafely(u64 address, u64* value); // bbsc_mod.cpp: no fault if unreadable
bool WriteSlotSafely(u64 address, u64 value);
void GuardSlot(u64 slot_offset, u64 wrapper, void** original, const char* name);
void RelayDispatch(const std::vector<u8>& wrapped); // bbsc_relay.cpp
} // namespace BbscMod
@@ -170,17 +172,16 @@ s32 PS4_SYSV_ABI ConnectionStatus(s32 ctx, s32 conn, u32* status, u32* addr, u16
template <typename Fn>
bool Swap(u64 slot_offset, Fn wrapper, Fn& original, const char* name) {
auto* slot = reinterpret_cast<u64*>(BbscMod::EbootAddress() + slot_offset);
const u64 slot = BbscMod::EbootAddress() + slot_offset;
for (int i = 0; i < 60'000; ++i) {
const u64 value = *slot;
if (value != 0 &&
u64 value = 0;
if (BbscMod::ReadSlotSafely(slot, &value) && 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);
if (!BbscMod::WriteSlotSafely(slot, reinterpret_cast<u64>(wrapper))) {
Sleep(1);
continue; // page not writable right now: try again
}
BbscMod::GuardSlot(slot_offset, reinterpret_cast<u64>(wrapper),
reinterpret_cast<void**>(&original), name);
return true;
@@ -192,8 +193,9 @@ bool Swap(u64 slot_offset, Fn wrapper, Fn& original, const char* name) {
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)) {
u64 value = 0;
if (!BbscMod::ReadSlotSafely(BbscMod::EbootAddress() + slot_offset, &value) || value == 0 ||
(value >= BbscMod::EbootAddress() && value < BbscMod::EbootAddress() + EbootSize)) {
return nullptr;
}
return reinterpret_cast<Fn>(value);
+74 -15
View File
@@ -1,34 +1,42 @@
// 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
// BBSC Discord status: "Playing BBSC - In Central Yharnam - Hosting - Arcane build (2 of 5)", the
// boss and a fight timer during a boss fight, "Dead - watching hunter 1 of 2" while spectating.
// 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.
// 2026-10-20: compiled into the emulator build too (before, only the mod showed it - the shadPS4
// package had only shadPS4's own "Playing Bloodborne"); the emulator then leaves its own status off.
#include "core/bloodborne_re.h"
#ifdef _WIN32
#include <windows.h>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <string>
#include <string_view>
#include <thread>
#include <fmt/format.h>
#include "core/bloodborne_re.h"
namespace BbscMod {
void LogPlain(const char* text);
}
#include "common/logging/log.h"
#include "core/bbsc_persistence.h"
namespace {
constexpr const char* DiscordApplicationId = "1554935855674691645"; // "BBSC" (CLS_AMG)
std::atomic<bool> discord_running{false};
bool DiscordStatusEnabled() {
const char* value = std::getenv("SHADPS4_BBSC_DISCORD");
return value == nullptr || (std::string{value} != "0" && std::string{value} != "false");
@@ -113,7 +121,7 @@ public:
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());
LOG_INFO(Debug, "Discord status: Discord closed the connection: {}", body);
Close();
return false;
}
@@ -144,6 +152,7 @@ std::string ActivityJson(const Core::Bloodborne::BbscPresence& p, std::int64_t g
if (!p.in_game) {
details = "At the title screen";
} else if (!p.boss.empty()) {
// The timer counts from when the boss bar appeared (Discord shows it as "mm:ss elapsed").
details = "Boss fight: " + p.boss;
large_text = "In " + p.area;
start = p.boss_since_s;
@@ -152,6 +161,20 @@ std::string ActivityJson(const Core::Bloodborne::BbscPresence& p, std::int64_t g
}
if (p.in_game) {
state = p.role == 1 ? "Hosting" : p.role == 2 ? "Co-op guest" : "Solo hunter";
if (p.dead) {
if (p.spectating && p.watch_count > 0) {
state += fmt::format(" - dead, watching hunter {} of {}", p.watching, p.watch_count);
} else if (p.spectating) {
state += " - dead, spectating";
} else {
state += " - dead";
}
} else if (!p.build.empty()) {
state += " - " + p.build;
}
if (!p.build.empty() && (p.dead || !p.boss.empty())) {
large_text += " - " + p.build;
}
}
std::string activity = "{\"details\":" + Json(details);
if (!state.empty()) {
@@ -187,7 +210,7 @@ DWORD WINAPI DiscordThread(LPVOID) {
continue;
}
if (!logged_connect) {
BbscMod::LogPlain("Discord status: connected to Discord");
LOG_INFO(Debug, "Discord status: connected to Discord");
logged_connect = true;
}
sent.clear();
@@ -212,17 +235,53 @@ DWORD WINAPI DiscordThread(LPVOID) {
} // namespace
namespace BbscMod {
namespace Core::Bbsc {
void StartDiscordStatus() {
bool DiscordStatusRunning() {
return discord_running.load();
}
void StartDiscordStatus(std::string_view serial) {
#ifndef BBSC_MOD
// The emulator build: only for the game BBSC knows, and only while its own BBSC runs (the mod
// shows the status itself when bbsc-launch switches these off).
if (serial != "CUSA03173" || !SwitchEnabled("SHADPS4_BLOODBORNE_RE_TRACE") ||
!SwitchEnabled("SHADPS4_BBSC_PERSIST")) {
return;
}
#endif
if (!DiscordStatusEnabled()) {
LogPlain("Discord status: off (Mods tab)");
LOG_INFO(Debug, "Discord status: off (Mods tab)");
return;
}
if (discord_running.exchange(true)) {
return;
}
if (HANDLE thread = CreateThread(nullptr, 0, DiscordThread, nullptr, 0, nullptr)) {
CloseHandle(thread);
LogPlain("Discord status: on - shows when the Discord app is running");
LOG_INFO(Debug, "Discord status: on - shows when the Discord app is running");
} else {
discord_running = false;
}
}
} // namespace Core::Bbsc
#else // not Windows: no Discord pipe
namespace Core::Bbsc {
bool DiscordStatusRunning() {
return false;
}
void StartDiscordStatus(std::string_view) {}
} // namespace Core::Bbsc
#endif
#ifdef BBSC_MOD
namespace BbscMod {
void StartDiscordStatus() {
Core::Bbsc::StartDiscordStatus("CUSA03173");
}
} // namespace BbscMod
#endif
+176 -69
View File
@@ -18,10 +18,14 @@
// - 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
// - summon request (a host ringing one sign): the host's placement header, AND the same text
// through the BBSC relay aimed at that sign's UserId only (TargetUserId of the claim) - 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).
// reply; the relay brings it instead (bbsc_relay.cpp -> BbscMod::OnAimedHostPlacement).
// 0.0.1 hotfix (2026-10-13): the search (/get) goes out unchanged. A search with a placement made the
// broker lock every sign outside the host's map ("Preparing") for that host, and the 0.0.1
// relay broadcast made waiting guests follow random hosts - on the public server nobody's
// Kin Bell sign could be rung.
// - 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).
@@ -36,7 +40,9 @@
#include <array>
#include <atomic>
#include <cctype>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <map>
#include <thread>
@@ -51,10 +57,16 @@
#include "common/types.h"
#include "core/bloodborne_re.h"
// One line in this run's BBSC timeline (bloodborne_re.cpp, "Timeline").
#define BBSC_TIMELINE(kind, ...) Core::Bloodborne::BbscTimelineEvent(kind, fmt::format(__VA_ARGS__))
namespace BbscMod {
void RelaySendPlacement(const std::string& placement);
void RelaySendAimedPlacement(s64 target_user, const std::string& placement);
u64 EbootAddress(); // bbsc_mod.cpp: where the game image is (0 until found)
bool ReadSlotSafely(u64 address, u64* value); // bbsc_mod.cpp: no fault if unreadable
bool WriteSlotSafely(u64 address, u64 value);
void GuardSlot(u64 slot_offset, u64 wrapper, void** original, const char* name); // bbsc_mod.cpp
std::vector<std::wstring> HostOverridesFiles(); // below: the emulator's overrides file candidates
}
namespace {
@@ -71,6 +83,9 @@ constexpr u64 SlotReadData = 0x053E5668;
// works; one that has not after this long is a dead claim.
constexpr s64 ClaimJoinWaitMs = 20'000;
constexpr s64 PlacementStaleMs = 20'000;
// An aimed placement (0.0.1 hotfix) comes once per claim; the game re-posts its sign about every 30 s,
// so it has to outlive that re-post.
constexpr s64 AimedPlacementStaleMs = 90'000;
using CreateConnectionFn = int PS4_SYSV_ABI (*)(int, const char*, bool);
using CreateRequestFn = int PS4_SYSV_ABI (*)(int, s32, const char*, u64);
@@ -105,6 +120,8 @@ constexpr s64 OwnSignMs = 60'000; // the game posts its sign again about every 3
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};
std::atomic<s64> own_user_id{0}; // UserId of our own sign (from its /create body)
std::atomic<bool> placement_aimed{false}; // the placement came aimed at our sign
s64 NowMs() {
return static_cast<s64>(GetTickCount64());
@@ -136,15 +153,7 @@ void LoadOverrides() {
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) {
for (const auto& path : BbscMod::HostOverridesFiles()) {
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)) {
@@ -159,11 +168,15 @@ void LoadOverrides() {
overrides.emplace_back(strings[i], strings[i + 1]);
}
if (!overrides.empty()) {
LOG_INFO(Mod, "HTTP: {} URL override(s) from host_overrides.json", overrides.size());
LOG_INFO(Mod, "HTTP: {} URL override(s) from {}", overrides.size(),
std::filesystem::path(path).string());
BBSC_TIMELINE("START", "HTTP: {} URL override(s) from {}", overrides.size(),
std::filesystem::path(path).string());
return;
}
}
LOG_INFO(Mod, "HTTP: no host_overrides.json - URLs unchanged");
BBSC_TIMELINE("START", "HTTP: no host_overrides.json - URLs unchanged");
}
// 0.0.0.0 is a server's "listen on everything", never an address to connect to. A game server
@@ -215,8 +228,11 @@ bool IsSummonDelete(const std::string& url) {
return Contains(url, "/summon_messenger/delete");
}
bool IsSummonClaim(const std::string& url) {
return Contains(url, "/summon_messenger/request");
}
bool IsSummonSearch(const std::string& url) {
return Contains(url, "/summon_messenger/request") || Contains(url, "/summon_messenger/get");
return Contains(url, "/summon_messenger/get");
}
// Public servers (2026-10-09): The Hunter's Dream (and the other community servers) let mods in as
@@ -349,6 +365,8 @@ void ClearStaleRecord(const std::string& url, const std::string& body) {
const DWORD status = Post(removal_url, removal);
LOG_INFO(Mod, "HTTP: stale summon cleanup for session {} user {}: status {}", session, user,
status);
BBSC_TIMELINE("JOIN", "HTTP: stale summon cleanup for session {} user {}: status {}", session, user,
status);
}
// ---- Stuck claims (2026-10-06) -------------------------------------------------------------------
@@ -380,6 +398,7 @@ void WatchSessionForStaleClaims() {
was_in_session = true;
if (claim_delivered_ms.exchange(0) != 0) {
LOG_INFO(Mod, "HTTP: the delivered claim became a join (session state {})", state);
BBSC_TIMELINE("JOIN", "HTTP: the delivered claim became a join (session state {})", state);
}
return;
}
@@ -389,6 +408,7 @@ void WatchSessionForStaleClaims() {
if (was_in_session.exchange(false)) {
stale_cleared = false;
LOG_INFO(Mod, "HTTP: session over - the next sign first clears our old broker record");
BBSC_TIMELINE("JOIN", "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) {
@@ -397,6 +417,9 @@ void WatchSessionForStaleClaims() {
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);
BBSC_TIMELINE("JOIN", "HTTP: a host's claim was delivered {} s ago and never became a join - "
"the next sign first clears it",
(NowMs() - delivered) / 1000);
}
}
@@ -420,7 +443,10 @@ int PS4_SYSV_ABI CreateRequestWithURL(int connection, s32 method, const char* ur
// 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);
u64 value = 0;
if (!BbscMod::ReadSlotSafely(base + slot_offset, &value)) {
return 0;
}
return value != 0 && (value < base || value >= base + EbootSize) ? value : 0;
}
@@ -433,6 +459,22 @@ int PS4_SYSV_ABI SendRequest(int request, const void* data, u64 size) {
}
}
WatchSessionForStaleClaims();
// Fewer messages / bloodstains per list (Panic 265 at Gascoigne / Cathedral Ward, see
// CapBbscAreaListRequest). Not on public servers: their API calls go out unchanged.
if (Contains(url, "/message_area") && !IsPublicServer(url) && data != nullptr && size != 0) {
if (const u64 length = CurrentSlot(SlotSetContentLength); length != 0) {
original_set_content_length = reinterpret_cast<SetContentLengthFn>(length);
}
std::string body(static_cast<const char*>(data), static_cast<size_t>(size));
const std::string changed = Core::Bloodborne::CapBbscAreaListRequest(url, body);
if (!changed.empty() && original_set_content_length != nullptr) {
original_set_content_length(request, body.size());
LOG_INFO(Mod, "HTTP: {}", changed);
// 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);
}
if (!IsSummon(url)) {
return original_send(request, data, size);
}
@@ -441,6 +483,8 @@ int PS4_SYSV_ABI SendRequest(int request, const void* data, u64 size) {
if (!logged.exchange(true)) {
LOG_INFO(Mod, "HTTP: summon requests go to a public server - sent unchanged (no "
"seamless headers, connect-first or cleanup)");
BBSC_TIMELINE("NET", "HTTP: summon requests go to a public server - sent unchanged (no "
"seamless headers, connect-first or cleanup)");
}
return original_send(request, data, size);
}
@@ -459,36 +503,58 @@ int PS4_SYSV_ABI SendRequest(int request, const void* data, u64 size) {
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()) {
if (IsSummonClaim(url)) {
// The host rang one sign: the broker gets our placement with the claim and only that
// sign's player hears it over the relay (it moves its sign into our map, then the broker
// delivers). Without a target nothing is sent - a claim with a placement nobody follows
// would stay "Claimed" until it times out.
const std::string body = data != nullptr && size != 0
? std::string(static_cast<const char*>(data),
static_cast<size_t>(size))
: std::string{};
const std::string target_text = JsonNumber(body, "TargetUserId");
const s64 target = target_text.empty() ? 0 : std::atoll(target_text.c_str());
const auto placement = Core::Bloodborne::GetSeamlessHostPlacementHeader();
if (target > 0 && placement) {
original_add_header(request, "X-ShadPS4-Bloodborne-Host-Placement", placement->c_str(),
0);
BbscMod::RelaySendPlacement(*placement);
BbscMod::RelaySendAimedPlacement(target, *placement);
LOG_INFO(Mod, "HTTP: ringing the sign of user {} - our placement {} sent to it", target,
*placement);
BBSC_TIMELINE("JOIN", "HTTP: ringing the sign of user {} - our placement {} sent to it", target,
*placement);
} else {
LOG_INFO(Mod, "HTTP: ringing a sign - no placement sent (target user {}, placement {})",
target_text.empty() ? std::string("unknown") : target_text,
placement ? "known" : "none");
BBSC_TIMELINE("JOIN", "HTTP: ringing a sign - no placement sent (target user {}, placement {})",
target_text.empty() ? std::string("unknown") : target_text,
placement ? "known" : "none");
}
return original_send(request, data, size);
}
if (IsSummonSearch(url)) {
return original_send(request, data, size); // searches go out unchanged (0.0.1 hotfix)
}
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);
if (const std::string user = JsonNumber(body, "UserId"); !user.empty()) {
const s64 id = std::atoll(user.c_str());
if (own_user_id.exchange(id) != id) {
LOG_INFO(Mod, "HTTP: our sign's user id is {}", id);
}
}
// 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) {
const s64 stale = placement_aimed.load() ? AimedPlacementStaleMs : PlacementStaleMs;
if (received != 0 && NowMs() - received > stale) {
placement_received_ms = 0;
placement_aimed = false;
placement_sender = 0;
Core::Bloodborne::ClearSeamlessHostPlacementHeader();
}
@@ -543,6 +609,9 @@ int PS4_SYSV_ABI ReadData(int request, void* data, u64 size) {
LOG_INFO(Mod, "HTTP: a host's claim came with our sign ({} bytes) - waiting for the "
"join",
read);
BBSC_TIMELINE("JOIN", "HTTP: a host's claim came with our sign ({} bytes) - waiting for the "
"join",
read);
}
}
}
@@ -562,16 +631,16 @@ int PS4_SYSV_ABI DeleteRequest(int request) {
// 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);
const u64 slot = 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)) {
u64 value = 0;
if (BbscMod::ReadSlotSafely(slot, &value) && 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);
if (!BbscMod::WriteSlotSafely(slot, reinterpret_cast<u64>(wrapper))) {
Sleep(1);
continue; // page not writable right now: try again
}
BbscMod::GuardSlot(slot_offset, reinterpret_cast<u64>(wrapper),
reinterpret_cast<void**>(&original), name);
return true;
@@ -583,10 +652,11 @@ bool Swap(u64 slot_offset, Fn wrapper, Fn& original, const char* name) {
// 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);
const u64 slot = 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)) {
u64 value = 0;
if (BbscMod::ReadSlotSafely(slot, &value) && value != 0 &&
(value < BbscMod::EbootAddress() || value >= BbscMod::EbootAddress() + EbootSize)) {
return value;
}
Sleep(1);
@@ -598,6 +668,38 @@ u64 WaitForSlot(u64 slot_offset) {
namespace BbscMod {
// The emulator's overrides file, in the order the emulator looks for it:
// 1. SHADPS4_HTTP_HOST_OVERRIDES_JSON - BBport (run.py) passes the file it uses this way: its own
// user\host_overrides.custom.json when present, else a user\host_overrides.json it writes for
// The Hunter's Dream. 0.0.1 read only the second -> BBport players with our server in the
// custom file ran BBSC in public mode (vanilla summons, no relay) and could not join anyone
// (2026-10-13 public reports).
// 2. user\host_overrides.custom.json (BBport layout, if the variable is missing).
// 3. user\host_overrides.json in the start folder / beside the exe (portable), then %APPDATA%.
std::vector<std::wstring> HostOverridesFiles() {
std::vector<std::wstring> files;
if (const wchar_t* env = _wgetenv(L"SHADPS4_HTTP_HOST_OVERRIDES_JSON");
env != nullptr && env[0] != L'\0') {
files.emplace_back(env);
}
wchar_t exe[MAX_PATH]{};
GetModuleFileNameW(nullptr, exe, MAX_PATH);
std::wstring dir{exe};
dir = dir.substr(0, dir.find_last_of(L"\\/") + 1);
wchar_t cwd[MAX_PATH]{};
GetCurrentDirectoryW(MAX_PATH, cwd);
const std::wstring start = std::wstring(cwd) + L"\\";
for (const std::wstring& base : {start, dir, dir + L"..\\"}) {
files.push_back(base + L"user\\host_overrides.custom.json");
}
files.push_back(start + L"user\\host_overrides.json");
files.push_back(dir + L"user\\host_overrides.json");
if (const wchar_t* appdata = _wgetenv(L"APPDATA")) {
files.push_back(std::wstring(appdata) + L"\\shadPS4\\host_overrides.json");
}
return files;
}
// 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);
@@ -606,38 +708,40 @@ bool IsPublicServerAddress(const std::string& 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) {
// Older hosts sent their placement to everyone whenever they searched; waiting guests then
// followed random hosts. Now we only follows a placement aimed at our own sign.
static std::atomic<bool> logged{false};
if (!logged.exchange(true)) {
LOG_INFO(Mod, "HTTP: placement broadcast from {:08x} ignored (an old host - it must "
"update BBSC): {}",
sender, placement);
BBSC_TIMELINE("JOIN", "HTTP: placement broadcast from {:08x} ignored (an old host - it must "
"update BBSC): {}",
sender, placement);
}
}
// A host rang our sign (0.0.1 hotfix): its placement, aimed at our sign's UserId. The next post of our
// sign moves it into the host's map and the broker delivers the claim.
void OnAimedHostPlacement(s64 target_user, const std::string& placement, u32 sender) {
const s64 own = own_user_id.load();
if (own == 0 || target_user != own) {
return; // another player's sign was rung
}
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;
}
// Its world flags are taken before the join load (bloodborne_re.cpp, "the host that rang").
Core::Bloodborne::NoteBbscHostRangOurSign(sender);
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;
placement_sender = sender;
placement_aimed = true;
placement_received_ms = NowMs();
LOG_INFO(Mod, "HTTP: host {:08x} rang our sign (user {}) - placement {}", sender, own,
placement);
BBSC_TIMELINE("JOIN", "HTTP: host {:08x} rang our sign (user {}) - placement {}", sender, own,
placement);
}
}
@@ -659,6 +763,9 @@ void InstallHttpWrappers() {
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");
BBSC_TIMELINE("START", "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
+104 -9
View File
@@ -40,6 +40,9 @@
#include "core/bbsc_cinematic.h"
#include "core/bbsc_patch_tables.h"
#include "core/bloodborne_re.h"
// One line in this run's BBSC timeline (bloodborne_re.cpp, "Timeline").
#define BBSC_TIMELINE(kind, ...) Core::Bloodborne::BbscTimelineEvent(kind, fmt::format(__VA_ARGS__))
#include "core/debugger.h"
#include "core/libraries/np/np_matching2/np_matching2_mm.h"
#include "core/memory.h"
@@ -48,7 +51,7 @@ 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"
#define BBSC_VERSION "0.0.2"
namespace BbscMod {
void LogPlain(const char* text);
@@ -328,11 +331,16 @@ DWORD WINAPI Worker(void*) {
OpenLog();
wchar_t exe[MAX_PATH]{};
GetModuleFileNameW(nullptr, exe, MAX_PATH);
BbscMod::LogPlain("BBSC mod loaded - version " BBSC_VERSION);
// The build time tells two DLLs of one version apart in a tester's log (2026-10-18 triage: no
// log said which 0.0.1 DLL a report came from).
BbscMod::LogPlain("BBSC mod loaded - version " BBSC_VERSION " (built " __DATE__ " " __TIME__ ")");
if (!NoticesPresent()) {
LOG_ERROR(Mod, "LICENSE.txt / NOTICE.txt / SUPPORTERS.txt missing or changed in {} - BBSC "
"is off",
Common::FS::PathToUTF8String(mod_dir));
BBSC_TIMELINE("START", "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 "
@@ -361,6 +369,9 @@ DWORD WINAPI Worker(void*) {
LOG_INFO(Mod, "Public game server ({}): vanilla summons - seamless summon patches and the "
"5-hunter search off (the session itself stays seamless)",
server);
BBSC_TIMELINE("START", "Public game server ({}): vanilla summons - seamless summon patches and the "
"5-hunter search off (the session itself stays seamless)",
server);
}
}
BuildSites();
@@ -371,6 +382,9 @@ DWORD WINAPI Worker(void*) {
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);
BBSC_TIMELINE("START", "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);
@@ -431,6 +445,7 @@ DWORD WINAPI Worker(void*) {
written = SyncSites();
}
LOG_INFO(Mod, "Image verified after {} ms: {} byte patch sites written", verified, written);
BBSC_TIMELINE("START", "Image verified after {} ms: {} byte patch sites written", verified, written);
Core::Bloodborne::InstallReverseEngineeringTrace();
BbscMod::StartRelay();
@@ -475,6 +490,63 @@ u64 EbootAddress() {
return EbootBase;
}
// Import slot reads and writes that never take the game down (2026-10-19, BBport player, 0.0.2
// test): the game closed 3 s after start, five times in a row - "Fault: read access to
// 00000008053e4f48 at bbsc.dll+0x1933": BBSC read the Sendto slot while BBport's loader had that
// page unreadable (it links the eboot's imports itself at start). A slot is only touched while
// its page is committed and readable, and a fault in between (the page changing right after the
// check) is caught here. Used by the slot guard below and by the swaps in bbsc_http.cpp and
// bbsc_channel.cpp.
namespace {
bool SlotPageReadable(u64 address) {
MEMORY_BASIC_INFORMATION info{};
if (VirtualQuery(reinterpret_cast<void*>(address), &info, sizeof(info)) == 0 ||
info.State != MEM_COMMIT || (info.Protect & (PAGE_GUARD | PAGE_NOACCESS)) != 0) {
return false;
}
constexpr DWORD Readable = PAGE_READONLY | PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READ |
PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY;
return (info.Protect & Readable) != 0;
}
bool TryReadSlot(const volatile u64* slot, u64* value) {
__try {
*value = *slot;
return true;
} __except (EXCEPTION_EXECUTE_HANDLER) {
return false;
}
}
bool TryWriteSlot(volatile u64* slot, u64 value) {
__try {
*slot = value;
return true;
} __except (EXCEPTION_EXECUTE_HANDLER) {
return false;
}
}
} // namespace
bool ReadSlotSafely(u64 address, u64* value) {
return SlotPageReadable(address) &&
TryReadSlot(reinterpret_cast<const volatile u64*>(address), value);
}
bool WriteSlotSafely(u64 address, u64 value) {
if (!SlotPageReadable(address)) {
return false;
}
DWORD old = 0;
if (!VirtualProtect(reinterpret_cast<void*>(address), 8, PAGE_READWRITE, &old)) {
return false;
}
const bool written = TryWriteSlot(reinterpret_cast<volatile u64*>(address), value);
DWORD ignored = 0;
VirtualProtect(reinterpret_cast<void*>(address), 8, old, &ignored);
return written;
}
// 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
@@ -492,22 +564,29 @@ std::mutex guarded_mutex;
std::vector<GuardedSlot> guarded_slots;
DWORD WINAPI SlotGuardThread(void*) {
int skipped = 0;
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;
const u64 slot = EbootBase + guarded.slot_offset;
u64 value = 0;
if (!ReadSlotSafely(slot, &value)) {
if (++skipped == 1) {
LOG_INFO(Mod, "Import {}: its page is not readable right now (the emulator is "
"still linking) - checked again later",
guarded.name);
}
continue;
}
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 (!WriteSlotSafely(slot, guarded.wrapper)) {
continue; // the next round tries again
}
if (++guarded.rewritten <= 3) {
LOG_INFO(Mod, "Import {} was linked again by the emulator - wrapper put back ({}x)",
guarded.name, guarded.rewritten);
@@ -732,6 +811,7 @@ struct CinePad {
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;
int switch_steps = 0; // R1 / L1 presses: the hunter to watch while dead
bool zoom_reset = false;
};
CinePad cine_pad;
@@ -773,6 +853,7 @@ int PS4_SYSV_ABI PadReadState(s32 handle, u8* data) {
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;
cine_pad.switch_steps += ((pressed & PadR1) ? 1 : 0) - ((pressed & PadL1) ? 1 : 0);
// The game sees a controller at rest (connected, nothing pressed).
const u32 rest_buttons = buttons & 0x80000000u;
std::memcpy(data, &rest_buttons, 4);
@@ -852,6 +933,15 @@ CinematicInput TakeCinematicInput() {
(CineKeyPressed(VK_OEM_4, down(VK_OEM_4)) ? 1 : 0) + p.smooth_steps;
cine_pad.speed_steps = 0;
cine_pad.smooth_steps = 0;
// Watching a living hunter while dead: Tab / E / left click next, Shift+Tab / Q previous,
// R1 / L1 on the controller; F flies freely instead and back (bloodborne_re.cpp).
const bool shift = down(VK_SHIFT);
c.switch_target = (CineKeyPressed(VK_TAB, down(VK_TAB)) ? (shift ? -1 : 1) : 0) +
(CineKeyPressed('E', down('E')) ? 1 : 0) -
(CineKeyPressed('Q', down('Q')) ? 1 : 0) +
(CineKeyPressed(VK_LBUTTON, down(VK_LBUTTON)) ? 1 : 0) + p.switch_steps;
cine_pad.switch_steps = 0;
c.toggle_free = CineKeyPressed('F', down('F'));
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);
@@ -902,6 +992,11 @@ void CinematicForceOn() {
cine_looking = false;
}
// The mod reads the controller itself (PadReadState: the whole pad goes to the camera while it is
// on), so watching needs nothing more here.
void CinematicSetWatching(bool) {}
void CinematicPadRead(unsigned, unsigned char, unsigned char, unsigned char, unsigned char) {}
} // namespace Bbsc
} // namespace Core
+125 -36
View File
@@ -20,6 +20,9 @@
#include <windows.h>
#include <atomic>
#include <condition_variable>
#include <cstdlib>
#include <deque>
#include <cstring>
#include <fstream>
#include <mutex>
@@ -35,7 +38,9 @@
namespace BbscMod {
void LogPlain(const char* text);
void OnHostPlacement(const std::string& placement, u32 sender);
void OnAimedHostPlacement(s64 target_user, const std::string& placement, u32 sender);
bool IsPublicServerAddress(const std::string& url_or_host); // bbsc_http.cpp
std::vector<std::wstring> HostOverridesFiles(); // bbsc_http.cpp
u32 P2PChannelSend(const void* data, u32 size); // bbsc_channel.cpp
u32 P2PChannelPeerCount();
}
@@ -44,7 +49,9 @@ 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 PlacementType = 0x30; // "SHAD" 0x30 + a host's placement text (before the hotfix, ignored now)
// "SHAD" 0x31 + "<target UserId>|<placement>": a host rang that sign (0.0.1 hotfix, bbsc_http.cpp)
constexpr u8 AimedPlacementType = 0x31;
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
@@ -77,6 +84,17 @@ 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;
// Frames wait here for the writer thread (2026-10-13): a public player's link dropped exactly 10 s
// after every reconnect while hosting - the 1/s world flags filled the socket while the server
// was stuck, and the game thread's 300 ms send timeout closed the link. Now the game thread only
// queues; a full queue drops frames instead of the link. All under send_mutex.
std::condition_variable send_cv;
std::deque<std::vector<char>> send_queue;
size_t send_queue_bytes = 0;
constexpr size_t SendQueueLimit = 256 * 1024;
SOCKET writing = INVALID_SOCKET; // the socket the writer is sending on right now
u32 dropped_frames = 0; // since this connection began
std::string lost_reason; // why the writer gave up on this connection
std::atomic<int> peers{0};
std::atomic<bool> started{false};
u32 own_id = 0; // set once in StartRelay, never 0
@@ -84,6 +102,7 @@ std::atomic<bool> logged_old_sender{false};
void Log(const std::string& text) {
BbscMod::LogPlain(("Relay: " + text).c_str());
Core::Bloodborne::BbscTimelineEvent("RELAY", text); // status changes only, see the callers
}
std::string ReadFile(const std::wstring& path) {
@@ -123,19 +142,7 @@ bool FindServer(std::string& host, u16& port) {
host = value;
return true;
}
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");
}
for (const auto& path : candidates) {
for (const auto& path : BbscMod::HostOverridesFiles()) {
host = HostFromOverrides(ReadFile(path));
if (!host.empty()) {
return true;
@@ -144,11 +151,14 @@ bool FindServer(std::string& host, u16& port) {
return false;
}
int last_read_result = 0; // 0 = the server closed the link, < 0 = recv error (relay thread only)
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) {
last_read_result = got == 0 ? 0 : -WSAGetLastError();
return false;
}
at += got;
@@ -185,21 +195,65 @@ u32 SendFrame(const void* data, u32 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);
if (send_queue_bytes + frame.size() > SendQueueLimit) {
if (dropped_frames++ == 0) {
Log("the server is not taking our frames (" + std::to_string(send_queue_bytes) +
" bytes waiting) - dropping new ones until it does");
}
at += sent;
left -= sent;
return static_cast<u32>(peers.load());
}
send_queue_bytes += frame.size();
send_queue.push_back(std::move(frame));
send_cv.notify_all();
return static_cast<u32>(peers.load());
}
std::string SocketError(const char* what) {
return std::string(what) + " (WSA error " + std::to_string(WSAGetLastError()) + ")";
}
// Sends the queued frames, one socket at a time. A failed send shuts the socket down; the relay
// thread sees that in recv and reconnects.
DWORD WINAPI WriterThread(void*) {
for (;;) {
std::vector<char> frame;
SOCKET s = INVALID_SOCKET;
{
std::unique_lock lock{send_mutex};
send_cv.wait(lock, [] { return !send_queue.empty(); });
frame = std::move(send_queue.front());
send_queue.pop_front();
send_queue_bytes -= frame.size();
s = relay;
if (s == INVALID_SOCKET) {
continue;
}
writing = s;
}
std::string problem;
int left = static_cast<int>(frame.size());
const char* at = frame.data();
while (left > 0) {
const int sent = send(s, at, left, 0);
if (sent <= 0) {
problem = SocketError("send failed");
break;
}
at += sent;
left -= sent;
}
std::scoped_lock lock{send_mutex};
writing = INVALID_SOCKET;
if (!problem.empty() && relay == s) {
if (lost_reason.empty()) {
lost_reason = problem;
}
shutdown(s, SD_BOTH);
}
send_cv.notify_all();
}
}
void Dispatch(const std::vector<u8>& wrapped) {
if (wrapped.size() == 2 && wrapped[0] == PeersType) {
peers = wrapped[1];
@@ -281,6 +335,13 @@ void Dispatch(const std::vector<u8>& wrapped) {
} 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] == AimedPlacementType) {
const std::string text(frame.begin() + 5, frame.end());
const size_t bar = text.find('|');
if (bar != std::string::npos && bar != 0) {
BbscMod::OnAimedHostPlacement(std::atoll(text.substr(0, bar).c_str()),
text.substr(bar + 1), sender);
}
} else if (frame[4] == Libraries::Np::NpSignaling::kSignalingBbscWorldFlagsType) {
if (from_our_host()) {
Core::Bloodborne::OnBbscWorldFlagsPacket(frame.data(), static_cast<u32>(frame.size()));
@@ -338,38 +399,63 @@ DWORD WINAPI RelayThread(void*) {
}
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_KEEPALIVE, reinterpret_cast<const char*>(&on), sizeof(on));
// Only the writer thread sends now (2026-10-13), so a slow server no longer stalls the
// game; a send stuck 10 s means the link is dead.
const DWORD send_timeout_ms = 10'000;
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;
lost_reason.clear();
dropped_frames = 0;
}
last_problem.clear();
Log("connected to " + host + ":" + std::to_string(port));
const u64 connected_at = GetTickCount64();
u64 frames_in = 0;
std::string reason;
for (;;) {
u32 size = 0;
if (!ReadAll(s, &size, 4) || size > 64 * 1024) {
if (!ReadAll(s, &size, 4)) {
break;
}
if (size > 64 * 1024) {
reason = "oversized frame from the server (" + std::to_string(size) + " bytes)";
break;
}
std::vector<u8> frame(size);
if (size != 0 && !ReadAll(s, frame.data(), static_cast<int>(size))) {
break;
}
++frames_in;
std::scoped_lock dispatch_lock{dispatch_mutex};
Dispatch(frame);
}
u32 dropped = 0;
{
std::scoped_lock lock{send_mutex};
if (relay == s) {
closesocket(relay);
relay = INVALID_SOCKET;
std::unique_lock lock{send_mutex};
if (!lost_reason.empty()) {
reason = lost_reason; // the writer gave up first
}
relay = INVALID_SOCKET;
shutdown(s, SD_BOTH); // wakes a writer still in send() on it
send_cv.wait(lock, [s] { return writing != s; });
closesocket(s);
send_queue.clear();
send_queue_bytes = 0;
dropped = dropped_frames;
}
if (reason.empty()) {
reason = last_read_result == 0
? std::string("the server closed the connection")
: "read failed (WSA error " + std::to_string(-last_read_result) + ")";
}
peers = 0;
Log("connection lost - reconnecting");
Log("connection lost after " + std::to_string((GetTickCount64() - connected_at) / 1000) +
" s, " + std::to_string(frames_in) + " frames in, " + std::to_string(dropped) +
" dropped out: " + reason + " - reconnecting");
Sleep(2'000);
}
}
@@ -402,10 +488,10 @@ void RelayDispatch(const std::vector<u8>& wrapped) {
Dispatch(wrapped);
}
void RelaySendPlacement(const std::string& placement) {
void RelaySendAimedPlacement(s64 target_user, const std::string& placement) {
std::string frame = "SHAD";
frame.push_back(static_cast<char>(PlacementType));
frame += placement;
frame.push_back(static_cast<char>(AimedPlacementType));
frame += std::to_string(target_user) + "|" + placement;
SendFrame(frame.data(), static_cast<u32>(frame.size()));
}
@@ -419,6 +505,9 @@ void StartRelay() {
if (HANDLE thread = CreateThread(nullptr, 0, RelayThread, nullptr, 0, nullptr)) {
CloseHandle(thread);
}
if (HANDLE thread = CreateThread(nullptr, 0, WriterThread, nullptr, 0, nullptr)) {
CloseHandle(thread);
}
}
}
} // namespace BbscMod
+4 -4
View File
@@ -1,14 +1,14 @@
@echo off
rem Builds the BBSC mod: bbsc.dll (injected into shadPS4) and bbsc-launch.exe (the injector).
rem Output: <repo>\build\mod\ ; log: <repo>\build\mod-build.log (ends with exit=N)
rem Output: D:\BBSC\build\mod\ ; log: D:\BBSC\build\mod-build.log (ends with exit=N)
rem bbsc.dll = bbsc_mod.cpp + bbsc_hooks.cpp + bbsc_relay.cpp + the emulator build's own
rem src\core\bloodborne_re.cpp (BBSC_MOD), with mod\shim\ answering its emulator includes.
call "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars64.bat" >nul || exit /b 1
set "PATH=C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\Llvm\x64\bin;%PATH%"
set "SRC=%~dp0"
set "OUT=%SRC%..\..\build\mod"
set "OBJ=%SRC%..\..\build\mod\obj"
set "LOG=%SRC%..\..\build\mod-build.log"
set "OUT=D:\BBSC\build\mod"
set "OBJ=D:\BBSC\build\mod\obj"
set "LOG=D:\BBSC\build\mod-build.log"
if not exist "%OBJ%" mkdir "%OBJ%"
set EXT=%SRC%..\externals
set FLAGS=/nologo /std:c++latest /O2 /MT /EHsc /W3 /DNOMINMAX /DWIN32_LEAN_AND_MEAN /D_CRT_SECURE_NO_WARNINGS /DBBSC_MOD /DFMT_HEADER_ONLY /I "%SRC%shim" /I "%SRC%..\src" /I "%EXT%\fmt\include" /I "%EXT%\xbyak"
+3 -2
View File
@@ -1,7 +1,7 @@
{
"id": "bbsc",
"name": "BBSC - Bloodborne Seamless Co-op",
"version": "0.0.1",
"version": "0.0.2",
"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"],
@@ -24,6 +24,7 @@
{"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": "bells_anywhere", "name": "Bells anywhere", "description": "The kit's bells ring anywhere, the Hunter's Dream included (your role, Insight and being online still count)", "type": "bool", "default": true, "env": "SHADPS4_BBSC_BELLS_ANYWHERE"},
{"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"},
@@ -36,7 +37,7 @@
{"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": "discord", "name": "Discord status", "description": "Shows BBSC, your area, your build, boss fights with a timer, when you are dead or spectating, 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"}
]
@@ -14,6 +14,7 @@
namespace Libraries::Np::NpSignaling {
inline constexpr u8 kSignalingBbscWorldFlagsType = 0x22;
inline constexpr u8 kSignalingBbscPvpType = 0x25;
// 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.
@@ -35,6 +36,15 @@ struct SignalingBbscWorldFlags {
u8 groups[kBbscWorldFlagGroupsPerChunk][kBbscWorldFlagGroupBytes]{};
};
static_assert(sizeof(SignalingBbscWorldFlags) == 18 + 8 * 125);
struct SignalingBbscPvp {
u8 magic[4] = {'S', 'H', 'A', 'D'};
u8 type = kSignalingBbscPvpType;
u8 kind = 0;
u8 on = 0;
u32 sequence = 0;
};
static_assert(sizeof(SignalingBbscPvp) == 11);
#pragma pack(pop)
} // namespace Libraries::Np::NpSignaling
+64
View File
@@ -7,7 +7,12 @@
// 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
// Dead in co-op it watches a living hunter instead (bloodborne_re.cpp BbscWatchCamera): Tab / E /
// left click next hunter, Shift+Tab / Q previous, F fly freely and back; the controller's R1 / L1
// and right stick reach it only then (CinematicPadRead - the F1 camera leaves the pad to the hunter).
#include <algorithm>
#include <cmath>
#include <mutex>
#include <SDL3/SDL_events.h>
@@ -23,6 +28,17 @@ namespace {
std::mutex cine_mutex;
CinematicInput cine_state;
bool looking = false;
bool watching = false; // the game thread says the camera watches a hunter
unsigned pad_last = 0; // pad buttons at the last read
int pad_switch = 0; // R1 / L1 presses while watching
float pad_rx = 0.0f, pad_ry = 0.0f, pad_ly = 0.0f;
float PadAxis(unsigned char 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);
}
bool CinematicEnabledBySwitch() {
static const bool enabled = SwitchEnabled("SHADPS4_BBSC_CINEMATIC");
@@ -101,11 +117,25 @@ bool CinematicHandleEvent(const SDL_Event& event, SDL_Window* window) {
if (key >= SDLK_F2 && key <= SDLK_F12) {
return false; // the emulator's own hotkeys (fullscreen, fps, pause...) keep working
}
if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat) {
if (key == SDLK_TAB) {
cine_state.switch_target += (event.key.mod & SDL_KMOD_SHIFT) != 0 ? -1 : 1;
} else if (key == SDLK_E) {
++cine_state.switch_target;
} else if (key == SDLK_Q) {
--cine_state.switch_target;
} else if (key == SDLK_F) {
cine_state.toggle_free = true;
}
}
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_LEFT && event.type == SDL_EVENT_MOUSE_BUTTON_DOWN) {
++cine_state.switch_target;
}
if (event.button.button == SDL_BUTTON_RIGHT) {
looking = event.type == SDL_EVENT_MOUSE_BUTTON_DOWN;
if (window != nullptr) {
@@ -133,6 +163,15 @@ bool CinematicHandleEvent(const SDL_Event& event, SDL_Window* window) {
CinematicInput TakeCinematicInput() {
std::scoped_lock lock{cine_mutex};
CinematicInput out = cine_state;
if (watching) {
out.pad_look_x = pad_rx;
out.pad_look_y = -pad_ry;
out.pad_move_y = -pad_ly; // stick up = closer
out.switch_target += pad_switch;
}
pad_switch = 0;
cine_state.switch_target = 0;
cine_state.toggle_free = false;
cine_state.mouse_dx = 0.0f;
cine_state.mouse_dy = 0.0f;
cine_state.wheel = 0;
@@ -155,4 +194,29 @@ void CinematicForceOn() {
}
}
void CinematicSetWatching(bool on) {
std::scoped_lock lock{cine_mutex};
if (watching != on) {
pad_switch = 0;
pad_rx = pad_ry = pad_ly = 0.0f;
}
watching = on;
}
void CinematicPadRead(unsigned buttons, unsigned char rx, unsigned char ry, unsigned char lx,
unsigned char ly) {
(void)lx;
std::scoped_lock lock{cine_mutex};
constexpr unsigned L1 = 0x400, R1 = 0x800;
const unsigned pressed = buttons & ~pad_last;
pad_last = buttons;
if (!cine_state.enabled || !watching) {
return;
}
pad_switch += ((pressed & R1) != 0 ? 1 : 0) - ((pressed & L1) != 0 ? 1 : 0);
pad_rx = PadAxis(rx);
pad_ry = PadAxis(ry);
pad_ly = PadAxis(ly);
}
} // namespace Core::Bbsc
+13
View File
@@ -31,6 +31,10 @@ struct CinematicInput {
// 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;
// Watching a living hunter while dead (2026-10-20): presses since the last call, + next /
// - previous (Tab, E, left click, R1 / Shift+Tab, Q, L1), and F (watch <-> fly freely).
int switch_target = 0;
bool toggle_free = false;
};
// Game thread: the current keys plus everything accumulated since the last call.
@@ -42,4 +46,13 @@ void CinematicForceOff();
// Game thread: switches the camera on (you died in co-op); nothing when it is already on.
void CinematicForceOn();
// Game thread: the camera is watching a living hunter (you are dead). Emulator build: only then
// does the controller reach the camera (CinematicPadRead) - the F1 camera leaves it to the hunter.
void CinematicSetWatching(bool watching);
// Emulator build, pad thread (scePadReadState): the game's pad data, read only. While watching,
// R1 / L1 switch the hunter and the right stick turns the camera.
void CinematicPadRead(unsigned buttons, unsigned char rx, unsigned char ry, unsigned char lx,
unsigned char ly);
} // namespace Core::Bbsc
+2 -2
View File
@@ -274,7 +274,7 @@ constexpr std::array<ByteEdit, 17> ByteEdits{{
// 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
// Five hunters (test 2026-10-08, host hardwater 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
@@ -360,7 +360,7 @@ constexpr std::array<WideEdit, 11> WideEdits{{
// (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
// Three players, 2026-10-08 (host hardwater, guest cls_amg; abood'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
+5 -1
View File
@@ -14,7 +14,7 @@ namespace Core::Bbsc {
// 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{
inline constexpr std::array<std::string_view, 35> 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
@@ -46,6 +46,10 @@ inline constexpr std::array<std::string_view, 31> DefaultOnSwitches{
"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)
"SHADPS4_BBSC_AREA_LIST_CAP", // fewer messages/bloodstains per request (Panic 265 crash)
"SHADPS4_BBSC_WORLD_SYNC_ALL", // the host's flags for every area, not just the one it is in
"SHADPS4_BBSC_TIMELINE", // a short bbsc-timeline-*.txt of what happened, next to the logs
"SHADPS4_BBSC_BELLS_ANYWHERE", // the kit's bells ring anywhere (Dream too); role / Insight still count
};
// False in the vanilla build (CMake BBSC_VANILLA): the emulator carries no BBSC of its own and
+481
View File
@@ -0,0 +1,481 @@
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
// SPDX-License-Identifier: GPL-2.0-or-later
// BBSC relay client for the emulator build - the emulator side of mod\bbsc_relay.cpp (same server,
// same frames: [u32 length]["SHAD" 0x40 + u32 sender + u32 session + the packet]).
//
// Why (2026-10-19, logs test-logs\2026-10-19-boss-session): the host (emulator build) rang the Duel
// Bell; the first guest (emulator build) got it over the P2P signaling, the third player (BBport +
// the mod) never did - his log has no "received the host's Duel Bell state: on" - so on his screen
// the others stayed friends (his hits went through them) while they could hit him. CONFIRMED: the
// emulator build never connected to the relay (no "Relay" line in either emulator log) and the mod
// cannot hear the emulator's signaling packets, so his mod took a stranger as its host by position
// ("our host is player 1136bcaf (10 hosting on the server, it stands 14.5 m from the host...)") and
// used that stranger's scaling, world flags, boss progress and PvP state.
//
// Sending: every BBSC packet the emulator sends to its signaling peers goes to the relay as well
// (np_signaling_helpers.cpp). Receiving: frames from the relay go to the same handlers the mod
// uses, with two limits that keep two emulator builds exactly as before - a guest ignores the
// relay while BBSC packets arrive over the signaling (its host is an emulator build), and life
// packets ("SHAD" 0x23) stay unhandled as the emulator's signaling receive always did.
#include "core/bbsc_relay_client.h"
#include <algorithm>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdlib>
#include <filesystem>
#include <cstring>
#include <fstream>
#include <mutex>
#include <sstream>
#include <thread>
#include <vector>
#include "common/logging/log.h"
#include "common/path_util.h"
#include "core/bbsc_persistence.h"
#include "core/bloodborne_re.h"
#include "core/libraries/np/np_signaling/np_signaling_bbsc.h"
#ifdef _WIN32
#include <winsock2.h>
#include <ws2tcpip.h>
#endif
namespace Core::Bbsc {
#ifdef _WIN32
namespace {
constexpr std::uint16_t DefaultRelayPort = 20444;
constexpr std::uint8_t FollowType = 0x20;
constexpr std::uint8_t ScaleType = 0x24;
constexpr std::uint8_t PvpType = 0x25;
constexpr std::uint8_t ProgressType = 0x26;
constexpr std::uint8_t HostBossType = 0x27;
constexpr std::uint8_t AimedPlacementType = 0x31;
constexpr std::uint8_t PeersType = 0x7F;
constexpr std::uint8_t EnvelopeType = 0x40;
constexpr size_t EnvelopeSize = 13;
constexpr std::int64_t SignalingFreshMs = 5'000;
constexpr size_t SendQueueLimit = 256 * 1024;
#pragma pack(push, 1)
struct FollowPacket {
std::uint8_t magic[4];
std::uint8_t type;
std::uint8_t kind;
std::uint32_t packed_map;
float x, y, z, heading;
std::uint32_t sequence;
std::int32_t lamp_point;
};
#pragma pack(pop)
static_assert(sizeof(FollowPacket) == 0x22);
std::atomic<bool> started{false};
std::uint32_t own_id = 0;
std::mutex send_mutex;
std::condition_variable send_cv;
std::vector<std::vector<char>> send_queue;
size_t send_queue_bytes = 0;
SOCKET relay = INVALID_SOCKET;
std::atomic<int> peers{0};
std::atomic<std::int64_t> signaling_ms{0};
std::atomic<std::int64_t> own_sign_user{0};
std::mutex dispatch_mutex;
std::int64_t NowMs() {
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
}
void Log(const std::string& text) {
LOG_INFO(Debug, "BBSC relay client: {}", text);
Core::Bloodborne::BbscTimelineEvent("RELAY", text);
}
std::string ReadFile(const std::filesystem::path& path) {
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(":/"));
}
// The same list as the mod's bbsc_http.cpp IsPublicServer: those servers run no BBSC relay.
bool IsPublicServer(const std::string& host) {
for (const char* known : {"thehuntersdream.com", "nodreamforthehunter.com", "shadps4.net",
"scej-network.jp"}) {
const size_t len = std::strlen(known);
if (host.size() >= len && host.compare(host.size() - len, len, known) == 0) {
return true;
}
}
return false;
}
// The game server the emulator's HTTP uses (http.cpp LoadHostOverrideState reads the same file).
bool FindServer(std::string& host, std::uint16_t& 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<std::uint16_t>(std::atoi(value.c_str() + colon + 1));
value.resize(colon);
}
host = value;
return true;
}
std::filesystem::path path;
if (const char* env = std::getenv("SHADPS4_HTTP_HOST_OVERRIDES_JSON"); env && env[0]) {
path = env;
} else {
path = Common::FS::GetUserPath(Common::FS::PathType::UserDir) / "host_overrides.json";
}
host = HostFromOverrides(ReadFile(path));
return !host.empty();
}
std::uint32_t SessionKey() {
const std::int32_t state = Core::Bloodborne::GetBbscRelaySessionState();
if (state == 3) {
return own_id;
}
if (state == 4 || state == 6) {
return Core::Bloodborne::GetBbscRelayHost();
}
return 0;
}
std::uint32_t SendFrame(const void* data, std::uint32_t size) {
std::scoped_lock lock{send_mutex};
if (relay == INVALID_SOCKET) {
return 0;
}
const std::uint32_t key = SessionKey();
const std::uint32_t wrapped = static_cast<std::uint32_t>(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 (send_queue_bytes + frame.size() > SendQueueLimit) {
return static_cast<std::uint32_t>(peers.load()); // the server is stuck: drop, keep the link
}
send_queue_bytes += frame.size();
send_queue.push_back(std::move(frame));
send_cv.notify_all();
return static_cast<std::uint32_t>(peers.load());
}
void WriterThread() {
for (;;) {
std::vector<char> frame;
SOCKET s = INVALID_SOCKET;
{
std::unique_lock lock{send_mutex};
send_cv.wait(lock, [] { return !send_queue.empty(); });
frame = std::move(send_queue.front());
send_queue.erase(send_queue.begin());
send_queue_bytes -= frame.size();
s = relay;
}
if (s == INVALID_SOCKET) {
continue;
}
int left = static_cast<int>(frame.size());
const char* at = frame.data();
while (left > 0) {
const int sent = send(s, at, left, 0);
if (sent <= 0) {
shutdown(s, SD_BOTH); // the relay thread sees it in recv and reconnects
break;
}
at += sent;
left -= sent;
}
}
}
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;
}
void Dispatch(const std::vector<std::uint8_t>& wrapped) {
if (wrapped.size() == 2 && wrapped[0] == PeersType) {
peers = wrapped[1];
return;
}
if (wrapped.size() < EnvelopeSize + 5 || std::memcmp(wrapped.data(), "SHAD", 4) != 0 ||
wrapped[4] != EnvelopeType) {
return; // an older BBSC without the envelope
}
std::uint32_t sender = 0;
std::uint32_t session = 0;
std::memcpy(&sender, wrapped.data() + 5, 4);
std::memcpy(&session, wrapped.data() + 9, 4);
const std::vector<std::uint8_t> frame(wrapped.begin() + EnvelopeSize, wrapped.end());
if (sender == own_id || std::memcmp(frame.data(), "SHAD", 4) != 0) {
return;
}
const std::int32_t state = Core::Bloodborne::GetBbscRelaySessionState();
const bool guest = state == 4 || state == 6;
const bool sender_hosting = session != 0 && session == sender;
const std::uint8_t type = frame[4];
if (type == FollowType && frame.size() >= 0x1E && sender_hosting) {
FollowPacket follow{};
std::memcpy(&follow, frame.data(), std::min(frame.size(), sizeof(follow)));
// Every host's position: the mod's host lock (bloodborne_re.cpp) picks ours from them.
Core::Bloodborne::NoteBbscRelayHost(sender, follow.packed_map, follow.x, follow.y,
follow.z);
}
if (type == AimedPlacementType) {
// A host rang a sign: ours -> that sender is the host we are joining.
const std::string text(frame.begin() + 5, frame.end());
const size_t bar = text.find('|');
const std::int64_t own = own_sign_user.load();
if (bar != std::string::npos && own != 0 &&
std::atoll(text.substr(0, bar).c_str()) == own && state != 3 && !guest) {
Core::Bloodborne::NoteBbscHostRangOurSign(sender);
Log(fmt::format("host {:08x} rang our sign (user {})", sender, own));
}
return;
}
if (type == PvpType && frame.size() >= 11 && frame[5] == 1) {
// A guest asks its host (us) for PvP on/off.
if (state == 3 && session == own_id) {
Core::Bloodborne::OnBbscPvpPacket(frame[5], frame[6]);
}
return;
}
// Everything below comes from a host for its guests.
if (!guest || !sender_hosting || sender != Core::Bloodborne::GetBbscRelayHost()) {
return;
}
if (NowMs() - signaling_ms.load() < SignalingFreshMs) {
return; // our host talks over the signaling (an emulator build): as before
}
if (type == FollowType && frame.size() >= 0x1E) {
FollowPacket follow{};
std::memcpy(&follow, frame.data(), std::min(frame.size(), sizeof(follow)));
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 (type == ScaleType && frame.size() >= 15) {
std::uint16_t percent[5]{};
std::memcpy(percent, frame.data() + 5, sizeof(percent));
Core::Bloodborne::OnBbscScalePacket(percent);
} else if (type == PvpType && frame.size() >= 11) {
Core::Bloodborne::OnBbscPvpPacket(frame[5], frame[6]);
} else if (type == ProgressType && frame.size() >= 6) {
const size_t count = std::min<size_t>(frame[5], (frame.size() - 6) / 4);
if (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 (type == HostBossType && frame.size() >= 9) {
std::int32_t boss = -1;
std::memcpy(&boss, frame.data() + 5, 4);
Core::Bloodborne::OnBbscHostBossPacket(boss);
} else if (type == Libraries::Np::NpSignaling::kSignalingBbscWorldFlagsType) {
Core::Bloodborne::OnBbscWorldFlagsPacket(frame.data(),
static_cast<std::uint32_t>(frame.size()));
}
}
void RelayThread() {
WSADATA wsa{};
WSAStartup(MAKEWORD(2, 2), &wsa);
std::string last_problem;
for (;;) {
std::string host;
std::uint16_t 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");
}
std::this_thread::sleep_for(std::chrono::seconds(10));
continue;
}
if (IsPublicServer(host)) {
if (last_problem != "public") {
last_problem = "public";
Log("public game server (" + host + ") - no BBSC relay there");
}
std::this_thread::sleep_for(std::chrono::seconds(30));
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 one without the relay) - retrying "
"every 5 s");
}
std::this_thread::sleep_for(std::chrono::seconds(5));
continue;
}
BOOL on = TRUE;
setsockopt(s, IPPROTO_TCP, TCP_NODELAY, reinterpret_cast<const char*>(&on), sizeof(on));
setsockopt(s, SOL_SOCKET, SO_KEEPALIVE, reinterpret_cast<const char*>(&on), sizeof(on));
const DWORD send_timeout_ms = 10'000;
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));
const std::int64_t connected_at = NowMs();
std::uint64_t frames_in = 0;
for (;;) {
std::uint32_t size = 0;
if (!ReadAll(s, &size, 4) || size > 64 * 1024) {
break;
}
std::vector<std::uint8_t> frame(size);
if (size != 0 && !ReadAll(s, frame.data(), static_cast<int>(size))) {
break;
}
++frames_in;
std::scoped_lock dispatch_lock{dispatch_mutex};
Dispatch(frame);
}
{
std::scoped_lock lock{send_mutex};
relay = INVALID_SOCKET;
send_queue.clear();
send_queue_bytes = 0;
}
shutdown(s, SD_BOTH);
closesocket(s);
peers = 0;
Log("connection lost after " + std::to_string((NowMs() - connected_at) / 1000) + " s, " +
std::to_string(frames_in) + " frames in - reconnecting");
std::this_thread::sleep_for(std::chrono::seconds(2));
}
}
} // namespace
void StartRelayClient(std::string_view serial) {
if (serial != "CUSA03173") {
return; // BBSC only knows the EU 1.09 game
}
// The emulator's own BBSC switched off (the mod runs in it instead: bbsc-launch sets these to
// 0) - the mod has its own relay link.
if (!Core::Bbsc::SwitchEnabled("SHADPS4_BLOODBORNE_RE_TRACE") ||
!Core::Bbsc::SwitchEnabled("SHADPS4_BBSC_PERSIST")) {
return;
}
if (const char* env = std::getenv("SHADPS4_BBSC_RELAY"); env != nullptr && env[0] == '0') {
Log("off (SHADPS4_BBSC_RELAY=0)");
return;
}
if (started.exchange(true)) {
return;
}
const auto seed = static_cast<std::uint64_t>(
std::chrono::high_resolution_clock::now().time_since_epoch().count());
own_id = static_cast<std::uint32_t>((seed ^ static_cast<std::uint64_t>(GetCurrentProcessId())) *
2654435761ull) |
1u;
std::thread(RelayThread).detach();
std::thread(WriterThread).detach();
}
std::uint32_t RelaySendPacket(const void* packet, std::uint32_t size) {
if (!started.load() || packet == nullptr || size < 5) {
return 0;
}
return SendFrame(packet, size);
}
void NoteSignalingBbscPacket() {
signaling_ms = NowMs();
}
void NoteOwnSignUserId(std::int64_t user_id) {
if (user_id > 0 && own_sign_user.exchange(user_id) != user_id) {
LOG_INFO(Debug, "BBSC relay client: our sign's user id is {}", user_id);
}
}
void RelaySendAimedPlacement(std::int64_t target_user, const std::string& placement) {
std::string frame = "SHAD";
frame.push_back(static_cast<char>(AimedPlacementType));
frame += std::to_string(target_user) + "|" + placement;
if (!started.load()) {
return;
}
const std::uint32_t reached = RelaySendPacket(frame.data(), static_cast<std::uint32_t>(frame.size()));
Log(fmt::format("ringing the sign of user {} - told it over the relay that we are its host "
"({} other player(s) on the relay)",
target_user, reached));
}
#else // not Windows: the relay client is Windows-only for now
void StartRelayClient(std::string_view) {}
std::uint32_t RelaySendPacket(const void*, std::uint32_t) {
return 0;
}
void NoteSignalingBbscPacket() {}
void NoteOwnSignUserId(std::int64_t) {}
void RelaySendAimedPlacement(std::int64_t, const std::string&) {}
#endif
} // namespace Core::Bbsc
+34
View File
@@ -0,0 +1,34 @@
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <cstdint>
#include <string>
#include <string_view>
// BBSC relay client for the emulator build (2026-10-19). The mod (mod\bbsc_relay.cpp) sends and
// takes BBSC's own packets through the game server's relay (TCP 20444); the emulator build only
// used its P2P signaling. A mod player and an emulator player in one session therefore never heard
// each other: a mod guest of an emulator host took a stranger's packets instead (see
// bbsc_relay_client.cpp). The emulator build now joins the relay too, with the same frames.
namespace Core::Bbsc {
// Connects to the game server's relay (host_overrides.json, or BBSC_RELAY=host[:port]).
// SHADPS4_BBSC_RELAY=0 keeps it off.
void StartRelayClient(std::string_view serial);
// One BBSC packet ("SHAD" + type ...) to the relay as well; returns the players it reached.
std::uint32_t RelaySendPacket(const void* packet, std::uint32_t size);
// A BBSC packet came over the P2P signaling: while those arrive, the relay's host packets are
// not needed (two emulator builds keep talking as before).
void NoteSignalingBbscPacket();
// The summon broker requests (http.cpp): our own sign's UserId (from its /create body) and a
// claim of another player's sign (the host rang it: its player learns over the relay which
// relay sender is its host, as with the mod).
void NoteOwnSignUserId(std::int64_t user_id);
void RelaySendAimedPlacement(std::int64_t target_user, const std::string& placement);
} // namespace Core::Bbsc
File diff suppressed because it is too large. Load diff
+22
View File
@@ -14,8 +14,15 @@ namespace Core::Bloodborne {
std::optional<std::string> GetSeamlessHostPlacementHeader();
bool SetSeamlessHostPlacementHeader(std::string_view value);
void ClearSeamlessHostPlacementHeader();
// BBSC timeline: one line in this run's bbsc-timeline-*.txt ("kind" like NET, RELAY, JOIN). Any
// thread; reads no game memory.
void BbscTimelineEvent(std::string_view kind, std::string_view text);
// BBSC connect-first cross-map summon: the host map a pending sign should advertise, if any.
std::optional<std::uint32_t> GetBbscConnectFirstAreaId();
// BBSC message / bloodstain lists: lowers every "GetCount" of a .../message_area request body to
// BBSC's cap (big lists run the game's heap dry - Panic 265). Returns what changed for the log, or
// an empty string when the request is not one of those or nothing was over the cap.
std::string CapBbscAreaListRequest(std::string_view path, std::string& body);
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.
@@ -41,6 +48,9 @@ void OnBbscProgressPacket(const std::uint32_t* flags, std::size_t count);
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();
// The relay sender of a host that rang our sign (aimed placement, mod): a joining guest takes that
// host's packets before it can match the host's position (busy servers). Relay thread.
void NoteBbscHostRangOurSign(std::uint32_t sender);
std::int32_t GetBbscRelaySessionState();
void InstallSeamlessCoopPatches();
void InstallReverseEngineeringTrace();
@@ -54,7 +64,19 @@ struct BbscPresence {
int hunters = 1; // players in the session
int max_hunters = 3;
std::int64_t boss_since_s = 0; // unix time the boss bar appeared
std::string build; // "Arcane build", "Quality build", ... (empty: no build yet)
bool dead = false; // the local player is down
bool spectating = false; // down in a co-op session while the others fight on
int watching = 0; // the watch camera's hunter (1-based), 0 = free camera
int watch_count = 0; // hunters it can watch
};
BbscPresence GetBbscPresence();
} // namespace Core::Bloodborne
namespace Core::Bbsc {
// BBSC's Discord status (mod/bbsc_discord.cpp, both builds; Windows only). The emulator build
// starts it from the eboot load; the emulator then leaves its own Discord status off.
void StartDiscordStatus(std::string_view serial);
bool DiscordStatusRunning();
} // namespace Core::Bbsc
+43 -1
View File
@@ -24,6 +24,7 @@
#include "common/logging/log.h"
#include "common/path_util.h"
#include "core/bbsc_persistence.h"
#include "core/bbsc_relay_client.h"
#include "core/bloodborne_re.h"
#include "core/debugger.h"
#include "core/emulator_settings.h"
@@ -839,6 +840,23 @@ static void RewriteConnectFirstAreaId(SendRequestPlan& plan) {
LOG_INFO(Lib_Http, "BBSC connect-first: advertisement AreaId {} -> {}", original, replacement);
}
// "...\"Key\":1234..." in a summon request body -> 1234 (0 when missing).
std::int64_t JsonBodyNumber(const std::vector<u8>& body, std::string_view key) {
const std::string_view text{reinterpret_cast<const char*>(body.data()), body.size()};
const std::string needle = "\"" + std::string(key) + "\":";
const size_t at = text.find(needle);
if (at == std::string_view::npos) {
return 0;
}
size_t end = at + needle.size();
while (end < text.size() && (std::isdigit(static_cast<unsigned char>(text[end])) != 0 ||
text[end] == '-')) {
++end;
}
const std::string number(text.substr(at + needle.size(), end - at - needle.size()));
return number.empty() ? 0 : std::atoll(number.c_str());
}
bool ApplyBloodborneSeamlessRoute(SendRequestPlan& plan) {
if (!EnvFlagEnabled("SHADPS4_BLOODBORNE_SEAMLESS_COOP") ||
!EmulatorSettings.IsShadNetEnabled() || !IsBloodborneSummonPath(plan.path)) {
@@ -870,12 +888,24 @@ bool ApplyBloodborneSeamlessRoute(SendRequestPlan& plan) {
plan.headers.emplace_back("X-ShadPS4-Bloodborne-Seamless", "1");
// BBSC: SHADPS4_BBSC_NO_PLACEMENT_HEADER=1 disables the host-placement transport in both
// directions (isolation test for the seamless join stall; cross-map preparation needs it).
// 0.0.2 (2026-10-13): only on the claim (/request), not on the search (/get) - a search with a
// placement locked every cross-map sign into "Preparing" for that one host, so on a public
// server with many hosts nobody's Kin Bell sign could be rung by anyone else.
if (!EnvFlagEnabled("SHADPS4_BBSC_NO_PLACEMENT_HEADER") &&
(IsBloodborneSummonRequestPath(plan.path) || IsBloodborneSummonSearchPath(plan.path))) {
IsBloodborneSummonRequestPath(plan.path)) {
if (const auto placement = Core::Bloodborne::GetSeamlessHostPlacementHeader()) {
plan.headers.emplace_back(BloodborneHostPlacementHeader, *placement);
// BBSC (2026-10-19): the rung sign's player also hears it over the relay, as the
// mod's hosts do - a mod guest takes its host's packets from that sender
// (bbsc_relay_client.cpp).
if (const std::int64_t target = JsonBodyNumber(plan.body, "TargetUserId"); target > 0) {
Core::Bbsc::RelaySendAimedPlacement(target, *placement);
}
}
}
if (IsBloodborneSummonCreatePath(plan.path)) {
Core::Bbsc::NoteOwnSignUserId(JsonBodyNumber(plan.body, "UserId"));
}
// BBSC connect-first cross-map summon: advertise the sign in the host's map so the broker
// matches and delivers the claim without the teleport-first step (see bloodborne_re.cpp).
@@ -1881,6 +1911,18 @@ int PS4_SYSV_ABI sceHttpSendRequest(int reqId, const void* postData, u64 size) {
}
ApplyBloodborneSeamlessRoute(plan);
// BBSC: fewer messages / bloodstains per message_area request (Panic 265, see bloodborne_re.cpp).
if (!plan.body.empty() && plan.path.find("/message_area") != std::string::npos) {
std::string body(plan.body.begin(), plan.body.end());
if (const std::string changed = Core::Bloodborne::CapBbscAreaListRequest(plan.path, body);
!changed.empty()) {
plan.body.assign(body.begin(), body.end());
std::erase_if(plan.headers, [](const auto& header) {
return HeaderNameMatches(header.first, "Content-Length");
});
LOG_INFO(Lib_Http, "{} (reqId={})", changed, reqId);
}
}
const bool online = EmulatorSettings.IsConnectedToNetwork();
LOG_INFO(Lib_Http, "reqId={} dispatched to async worker [{} {} {}://{}:{}{}]", reqId,
@@ -23,6 +23,7 @@
// clang-format on
#include "common/assert.h"
#include "common/logging/log.h"
#include "core/bloodborne_re.h"
#include "common/singleton.h"
#include "core/libraries/kernel/kernel.h"
#include "core/libraries/network/net_util.h"
@@ -489,13 +490,14 @@ public:
// receiver files the frame under the same endpoint its signaling code uses for that peer (an
// ActivationRequest answer goes to the packet's mapped_addr + the frame's source port).
// A peer that reaches us through the relay is answered through it at once.
// SHADPS4_P2P_RELAY=0 turns it off, =force relays every known peer from the first frame;
// SHADPS4_P2P_RELAY_PORT changes the port.
// Public players can be behind CGNAT or strict routers, so use the relay from the first frame.
// Direct copies still go out until the peer answers. SHADPS4_P2P_RELAY=auto (or direct)
// opts into direct-first with relay fallback after 2 s; =0/off disables the relay.
void RelaySetServer(u32 addr_nbo) {
std::lock_guard lock(relay_mutex);
const char* env = std::getenv("SHADPS4_P2P_RELAY");
const std::string mode = env != nullptr ? env : "";
relay_force = mode == "force";
relay_force = mode != "auto" && mode != "direct";
u16 port = 3478;
if (const char* p = std::getenv("SHADPS4_P2P_RELAY_PORT"); p != nullptr && *p != 0) {
const long parsed = std::strtol(p, nullptr, 10);
@@ -561,21 +563,15 @@ public:
}
std::lock_guard lock(relay_mutex);
const std::string id = PadId(online_id);
const u64 key = EndpointKey(addr_nbo, port_nbo);
if (const auto old = relay_ids.find(id); old != relay_ids.end() && old->second != key) {
// The peer moved (new run, new port): carry the path choice over.
const auto old_peer = relay_peers.find(old->second);
if (old_peer != relay_peers.end()) {
RelayPeer moved = old_peer->second;
relay_peers.erase(old_peer);
relay_peers[key] = moved;
}
}
relay_ids[id] = key;
RelayPeer& peer = relay_peers[key];
// Peers are kept by online id; every endpoint signaling or the relay ever gave for it
// stays an alias. (Before 2026-10-13 the peer was filed under ONE endpoint and moved when
// the relay brought another one - signaling's sends to the endpoint it knows then found
// no relay entry and went direct only: one-way relay, DEAD after 30 s behind CGNAT.)
RelayPeer& peer = relay_peers[id];
peer.id = id;
peer.addr = addr_nbo;
peer.addr = addr_nbo; // the endpoint signaling uses: relayed frames arrive "from" it
peer.port = port_nbo;
AddRelayAliasLocked(id, addr_nbo, port_nbo, "signaling");
}
private:
@@ -622,6 +618,31 @@ private:
return (static_cast<u64>(addr_nbo) << 16) | port_nbo;
}
// relay_mutex held.
void AddRelayAliasLocked(const std::string& id, u32 addr_nbo, u16 port_nbo, const char* from) {
if (addr_nbo == 0 || port_nbo == 0) {
return;
}
const u64 key = EndpointKey(addr_nbo, port_nbo);
const auto it = relay_alias.find(key);
if (it != relay_alias.end() && it->second == id) {
return;
}
relay_alias[key] = id;
LOG_INFO(Lib_Net, "P2P relay fallback: '{}' known at {:#x}:{} (from {})", IdText(id),
addr_nbo, ntohs(port_nbo), from);
}
// relay_mutex held.
RelayPeer* FindRelayPeerLocked(u32 addr_nbo, u16 port_nbo) {
const auto alias = relay_alias.find(EndpointKey(addr_nbo, port_nbo));
if (alias == relay_alias.end()) {
return nullptr;
}
const auto it = relay_peers.find(alias->second);
return it == relay_peers.end() ? nullptr : &it->second;
}
bool IsRelayServer(u32 addr_nbo, u16 port_nbo) {
std::lock_guard lock(relay_mutex);
return relay_server_addr != 0 && addr_nbo == relay_server_addr &&
@@ -658,7 +679,7 @@ private:
relay_status_sent = relay_sent;
relay_status_received = relay_received;
std::string peers;
for (const auto& [key, peer] : relay_peers) {
for (const auto& [id, peer] : relay_peers) {
if (!peers.empty()) {
peers += ", ";
}
@@ -688,11 +709,11 @@ private:
dest_addr_nbo == relay_server_addr) {
return RelayPath::Direct;
}
const auto it = relay_peers.find(EndpointKey(dest_addr_nbo, dest_port_nbo));
if (it == relay_peers.end()) {
RelayPeer* const found = FindRelayPeerLocked(dest_addr_nbo, dest_port_nbo);
if (found == nullptr) {
return RelayPath::Direct;
}
RelayPeer& peer = it->second;
RelayPeer& peer = *found;
const auto now = std::chrono::steady_clock::now();
if (peer.relayed && now < peer.missing_until) {
return RelayPath::Direct;
@@ -758,9 +779,9 @@ private:
if (relay_peers.empty()) {
return;
}
const auto it = relay_peers.find(EndpointKey(addr_nbo, port_nbo));
if (it != relay_peers.end() && !it->second.direct_rx) {
RelayPeer& peer = it->second;
RelayPeer* const found = FindRelayPeerLocked(addr_nbo, port_nbo);
if (found != nullptr && !found->direct_rx) {
RelayPeer& peer = *found;
peer.direct_rx = true;
peer.early.clear();
if (peer.relayed && !peer.relay_seen) {
@@ -791,9 +812,9 @@ private:
if (data[4] == 0x05 && size >= 5 + kRelayIdSize) {
const std::string id(reinterpret_cast<const char*>(data + 5), kRelayIdSize);
std::lock_guard lock(relay_mutex);
const auto known = relay_ids.find(id);
if (known != relay_ids.end()) {
RelayPeer& peer = relay_peers[known->second];
const auto known = relay_peers.find(id);
if (known != relay_peers.end()) {
RelayPeer& peer = known->second;
peer.missing_until = std::chrono::steady_clock::now() + std::chrono::seconds(10);
if (!peer.missing_logged) {
peer.missing_logged = true;
@@ -802,6 +823,9 @@ private:
"older than 2026-10-12, or it is not running) - direct only; if "
"direct does not work either, it cannot connect until it updates",
IdText(peer.id));
Core::Bloodborne::BbscTimelineEvent(
"NET", fmt::format("'{}' is not on the relay (old emulator or not running)",
IdText(peer.id)));
}
}
return;
@@ -818,35 +842,24 @@ private:
u16 from_port = 0;
{
std::lock_guard lock(relay_mutex);
if (ep_addr != 0 && ep_port != 0) {
// File the peer under the endpoint it gave (moves an earlier made-up one).
const u64 key = EndpointKey(ep_addr, ep_port);
if (const auto old = relay_ids.find(id); old != relay_ids.end() && old->second != key) {
const auto old_peer = relay_peers.find(old->second);
if (old_peer != relay_peers.end()) {
RelayPeer moved = old_peer->second;
relay_peers.erase(old_peer);
relay_peers[key] = moved;
}
}
relay_ids[id] = key;
RelayPeer& filed = relay_peers[key];
filed.id = id;
filed.addr = ep_addr;
filed.port = ep_port;
}
if (!relay_ids.contains(id)) {
// A peer we never looked up: give it a made-up endpoint (169.254.x.x, never sent
// to directly) so the answers go back through the relay.
const u32 addr = htonl(0xA9FE0000u | (relay_next_pseudo++ & 0xFFFFu));
const u16 port = htons(3173);
relay_ids[id] = EndpointKey(addr, port);
RelayPeer& made = relay_peers[EndpointKey(addr, port)];
if (!relay_peers.contains(id)) {
// A peer signaling never looked up: the endpoint it gave, else a made-up one
// (169.254.x.x, never sent to directly) so the answers go back through the relay.
RelayPeer& made = relay_peers[id];
made.id = id;
made.addr = addr;
made.port = port;
if (ep_addr != 0 && ep_port != 0) {
made.addr = ep_addr;
made.port = ep_port;
} else {
made.addr = htonl(0xA9FE0000u | (relay_next_pseudo++ & 0xFFFFu));
made.port = htons(3173);
}
AddRelayAliasLocked(id, made.addr, made.port, "relay, new peer");
}
RelayPeer& peer = relay_peers[relay_ids[id]];
// Its own endpoint is one more alias; the peer keeps the endpoint signaling knows,
// so the frame below arrives from where signaling expects it.
AddRelayAliasLocked(id, ep_addr, ep_port, "relay");
RelayPeer& peer = relay_peers[id];
peer.relay_seen = true;
peer.missing_until = {};
if (!peer.relayed) {
@@ -874,8 +887,8 @@ private:
u16 relay_self_port{0};
std::chrono::steady_clock::time_point relay_last_register{};
std::vector<std::string> relay_local_ids;
std::unordered_map<u64, RelayPeer> relay_peers;
std::unordered_map<std::string, u64> relay_ids;
std::unordered_map<std::string, RelayPeer> relay_peers; // by padded online id
std::unordered_map<u64, std::string> relay_alias; // every known endpoint -> online id
u32 relay_next_pseudo{1};
u64 relay_sent{0};
u64 relay_received{0};
@@ -14,6 +14,7 @@
namespace Libraries::Np::NpSignaling {
inline constexpr u8 kSignalingBbscWorldFlagsType = 0x22;
inline constexpr u8 kSignalingBbscPvpType = 0x25;
// 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.
@@ -35,6 +36,16 @@ struct SignalingBbscWorldFlags {
u8 groups[kBbscWorldFlagGroupsPerChunk][kBbscWorldFlagGroupBytes]{};
};
static_assert(sizeof(SignalingBbscWorldFlags) == 18 + 8 * 125);
// Duel Bell state. kind 0 is the host's authoritative state; kind 1 is a guest request.
struct SignalingBbscPvp {
u8 magic[4] = {'S', 'H', 'A', 'D'};
u8 type = kSignalingBbscPvpType;
u8 kind = 0;
u8 on = 0;
u32 sequence = 0;
};
static_assert(sizeof(SignalingBbscPvp) == 11);
#pragma pack(pop)
} // namespace Libraries::Np::NpSignaling
@@ -19,6 +19,7 @@
#include "core/libraries/np/np_signaling/np_signaling_helpers.h"
#include "core/libraries/np/np_signaling/np_signaling_state.h"
#include "core/libraries/np/np_signaling/np_signaling_stubs.h"
#include "core/bbsc_relay_client.h"
#include "core/bloodborne_re.h"
namespace Libraries::Np::NpSignaling::Helpers {
@@ -181,15 +182,14 @@ u32 SendBbscWorldFlagsToPeers(const void* packet, u32 size) {
++sent;
}
}
// BBSC (2026-10-19): and to the game server's relay, where mod players (BBport...) listen.
Core::Bbsc::RelaySendPacket(packet, size);
return sent;
}
u32 SendBbscFollowToPeers(u32 packed_map, float x, float y, float z, float heading, u32 sequence,
s32 lamp_point) {
const std::vector<BbscPeer> peers = EstablishedBbscPeers();
if (peers.empty()) {
return 0;
}
SignalingBbscFollow packet{};
packet.packed_map = packed_map;
packet.x = x;
@@ -204,6 +204,8 @@ u32 SendBbscFollowToPeers(u32 packed_map, float x, float y, float z, float headi
++sent;
}
}
// BBSC (2026-10-19): the relay too - a mod guest finds its host there by this position.
Core::Bbsc::RelaySendPacket(&packet, sizeof(packet));
return sent;
}
@@ -377,6 +379,7 @@ static void ReceiveThreadMain() {
type == static_cast<u8>(SignalingPacketType::BbscFollow)) {
SignalingBbscFollow follow{}; // lamp_point stays -1 for an older build's packet
std::memcpy(&follow, buf, static_cast<size_t>(nbytes));
Core::Bbsc::NoteSignalingBbscPacket();
Core::Bloodborne::OnBbscHostFollowPacket(follow.packed_map, follow.x, follow.y,
follow.z, follow.heading, follow.sequence,
follow.lamp_point);
@@ -384,9 +387,36 @@ static void ReceiveThreadMain() {
}
if (nbytes == sizeof(SignalingBbscWorldFlags) &&
type == static_cast<u8>(SignalingPacketType::BbscWorldFlags)) {
Core::Bbsc::NoteSignalingBbscPacket();
Core::Bloodborne::OnBbscWorldFlagsPacket(buf, static_cast<u32>(nbytes));
continue;
}
if (nbytes == sizeof(SignalingBbscPvp) &&
type == static_cast<u8>(SignalingPacketType::BbscPvp)) {
SignalingBbscPvp pvp{};
std::memcpy(&pvp, buf, sizeof(pvp));
if (pvp.kind == 0) {
Core::Bbsc::NoteSignalingBbscPacket(); // kind 0 = the host's state
}
Core::Bloodborne::OnBbscPvpPacket(pvp.kind, pvp.on);
continue;
}
// BBSC (2026-10-19): the host's progress ("SHAD" 0x26 + u8 count + count x u32 flags it has
// on) and the boss on its boss bar ("SHAD" 0x27 + s32). The mod's relay took them all along;
// this receive dropped them as unexpected, so a guest on the emulator build never got the
// host's boss flags for shared progress, nor (now) its time of night. Only a host sends them.
if (type == 0x26 && nbytes >= 6 && nbytes == 6 + 4 * static_cast<size_t>(buf[5])) {
std::vector<u32> flags(buf[5]);
std::memcpy(flags.data(), buf + 6, flags.size() * 4);
Core::Bloodborne::OnBbscProgressPacket(flags.data(), flags.size());
continue;
}
if (type == 0x27 && nbytes == 9) {
s32 boss = -1;
std::memcpy(&boss, buf + 5, 4);
Core::Bloodborne::OnBbscHostBossPacket(boss);
continue;
}
if (nbytes == sizeof(SignalingEchoPong) &&
type == static_cast<u8>(SignalingPacketType::EchoPong)) {
SignalingEchoPong pong{};
@@ -10,6 +10,7 @@
#include "common/logging/log.h"
#include "common/singleton.h"
#include "core/bloodborne_re.h"
#include "core/libraries/error_codes.h"
#include "core/libraries/kernel/kernel.h"
#include "core/libraries/kernel/process.h"
@@ -727,6 +728,13 @@ void CloseConnectionAndDispatchDead(s32 conn_id, s32 error_code) {
: static_cast<u32>(error_code) == static_cast<u32>(ORBIS_NP_SIGNALING_ERROR_TERMINATED_BY_PEER) ? ": closed by the peer"
: static_cast<u32>(error_code) == static_cast<u32>(ORBIS_NP_SIGNALING_ERROR_TERMINATED_BY_MYSELF) ? ": closed by us"
: "");
// BBSC timeline: a lost connection to another player, without its address.
Core::Bloodborne::BbscTimelineEvent(
"NET", fmt::format("connection to '{}' lost ({:#x}{})", peer, static_cast<u32>(error_code),
static_cast<u32>(error_code) ==
static_cast<u32>(ORBIS_NP_SIGNALING_ERROR_TIMEOUT)
? ": no answer"
: ""));
DispatchConnectionEvent(conn_id, ORBIS_NP_SIGNALING_EVENT_DEAD, error_code);
SignalingMutexGuard lock;
RemoveConnectionLocked(conn_id);
@@ -797,6 +805,8 @@ void EstablishConnection(s32 conn_id, bool peer_activated_hint) {
LOG_INFO(Lib_NpSignaling, "Connection {} -> ESTABLISHED ({}) peer at {:#x}:{}", conn_id,
fire_established ? "owner" : "answerer/non-owner", peer_addr,
sceNetNtohs(peer_port));
Core::Bloodborne::BbscTimelineEvent(
"NET", fmt::format("connection {} to another player established", conn_id));
}
if (fire_peer_activated) {
DispatchPeerActivatedEvent(conn_id);
@@ -63,8 +63,11 @@ enum class SignalingPacketType : u8 {
// the host's world state (bosses, doors, shortcuts) instead of whatever its own flags say.
// 0x21 is taken (Matching2 frames, see P2PTransport::ClassifyInternal).
BbscWorldFlags = 0x22,
// BBSC: Duel Bell PvP state. The host broadcasts its state and a guest requests a toggle.
BbscPvp = 0x25,
};
static_assert(static_cast<u8>(SignalingPacketType::BbscWorldFlags) == kSignalingBbscWorldFlagsType);
static_assert(static_cast<u8>(SignalingPacketType::BbscPvp) == kSignalingBbscPvpType);
#pragma pack(push, 1)
struct SignalingEchoPing {
+8
View File
@@ -4,6 +4,7 @@
#include "common/elf_info.h"
#include "common/logging/log.h"
#include "common/singleton.h"
#include "core/bbsc_cinematic.h"
#include "core/emulator_settings.h"
#include "core/libraries/libs.h"
#include "core/libraries/pad/pad_errors.h"
@@ -467,6 +468,13 @@ int PS4_SYSV_ABI scePadReadHistory() {
int PS4_SYSV_ABI scePadReadState(s32 handle, OrbisPadData* pData) {
LOG_TRACE(Lib_Pad, "handle: {}", handle);
const int result = scePadRead(handle, pData, 1);
if (result > 0 && pData != nullptr) {
// BBSC: dead in co-op, the camera watching a living hunter reads R1 / L1 and the sticks
// (bbsc_cinematic.cpp). The game's data is not changed.
Core::Bbsc::CinematicPadRead(static_cast<u32>(pData->buttons), pData->rightStick.x,
pData->rightStick.y, pData->leftStick.x,
pData->leftStick.y);
}
return result < 0 ? result : ORBIS_OK;
}
+3
View File
@@ -10,6 +10,7 @@
#include "common/string_util.h"
#include "core/aerolib/aerolib.h"
#include "core/bbsc_persistence.h"
#include "core/bbsc_relay_client.h"
#include "core/bloodborne_re.h"
#include "imgui/bbsc_intro_layer.h"
#include "core/cpu_patches.h"
@@ -360,6 +361,8 @@ void Module::LoadModuleToMemory(u32& max_tls_index) {
Bloodborne::InstallSeamlessCoopPatches();
Bbsc::InstallPersistentSessionPatches();
Bloodborne::InstallReverseEngineeringTrace();
Bbsc::StartRelayClient(MemoryPatcher::g_game_serial);
Bbsc::StartDiscordStatus(MemoryPatcher::g_game_serial);
Bbsc::StartIntro(MemoryPatcher::g_game_serial);
} else {
LOG_INFO(Core_Linker, "BBSC build: vanilla - no built-in BBSC, it runs as the "
+4 -1
View File
@@ -30,6 +30,7 @@
#include "common/polyfill_thread.h"
#include "common/scm_rev.h"
#include "common/singleton.h"
#include "core/bloodborne_re.h" // BBSC Discord status
#include "core/cpu_patches.h" // Windows static guest red-zone protection
#include "core/debugger.h"
#include "core/devtools/widget/module_list.h"
@@ -678,7 +679,9 @@ void Emulator::Run(std::filesystem::path file, std::vector<std::string> args,
// Discord RPC - not when the BBSC mod shows its own status ("Discord status" in the Mods tab,
// set by ApplyEnvironment above), or Discord would show both.
const char* bbsc_discord = std::getenv("SHADPS4_BBSC_DISCORD");
const bool bbsc_status = bbsc_discord != nullptr && std::string_view{bbsc_discord} == "1";
// The emulator's built-in BBSC shows the same status (started at the eboot load, module.cpp).
const bool bbsc_status = (bbsc_discord != nullptr && std::string_view{bbsc_discord} == "1") ||
Core::Bbsc::DiscordStatusRunning();
if (bbsc_status) {
LOG_INFO(Loader, "Discord RPC: left to the BBSC mod's Discord status");
}
+2 -2
View File
@@ -210,8 +210,8 @@ struct GeneralSettings {
Setting<bool> show_fps_counter{false};
Setting<int> console_language{1};
Setting<int> big_picture_scale{1000};
Setting<std::string> shadnet_server{"srv.shadps4.net:31313"};
Setting<std::string> shadnet_webapi_server{"http://srv.shadps4.net:31315"};
Setting<std::string> shadnet_server{"play.seamlesshunt.com:31313"};
Setting<std::string> shadnet_webapi_server{"http://play.seamlesshunt.com:31315"};
Setting<std::string> signaling_info{};
Setting<bool> enable_upnp{true};
+61 -9
View File
@@ -422,8 +422,10 @@ def _matches(request, sign):
types = {f.get("SummonType") for f in request.get("SummonTypeList") or [] if "SummonType" in f}
if types and sign.get("SummonType") not in types:
return False
# The password must be the same on both sides, as in the game (2026-10-19): a search with no
# password used to match passworded signs too. None/absent = no password.
word = request.get("SummonWord") or ""
if word and word != (sign.get("SummonWord") or ""):
if word != (sign.get("SummonWord") or ""):
return False
level, theirs = request.get("MatchingLevel"), sign.get("MatchingLevel")
if not word and isinstance(level, int) and isinstance(theirs, int) and level >= 0 and theirs >= 0:
@@ -621,6 +623,13 @@ SHAD_MAGIC = b"BBR1"
class Relay:
def __init__(self):
self.sock = None
# Observability only. No changes to the BBHOST or BBR1 wire protocols.
self.datagrams_in = 0
self.bytes_in = 0
self.datagrams_out = 0
self.bytes_out = 0
self.send_errors = 0
self.unrecognized_in = 0
self.by_token = {} # token -> port
self.ports = {} # port -> {"token", "addr": (ip, port), "seen"}
self.frames = 0
@@ -628,13 +637,48 @@ class Relay:
self.shad_frames = 0
self.shad_missing = {} # (src, dst) -> (last told, last logged) for unregistered dst
def start(self, bind, port):
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.sock.bind((bind, port))
def start(self, bind, port, rcvbuf_bytes=0):
# Explicit opt-in only. Linux doubles SO_RCVBUF for accounting and caps
# the requested value using net.core.rmem_max (unless SO_RCVBUFFORCE).
# Log the observed value; changing sysctl alone does not resize a socket.
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
if rcvbuf_bytes:
if not 8192 <= rcvbuf_bytes <= 64 * 1024 * 1024:
raise ValueError("UDP receive buffer must be 8192..67108864 bytes")
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, rcvbuf_bytes)
sock.bind((bind, port))
except Exception:
sock.close()
raise
self.sock = sock
actual = sock.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF)
if rcvbuf_bytes:
say("UDP SO_RCVBUF: requested {} bytes; kernel reports {} bytes (Linux accounting includes 2x)".format(
rcvbuf_bytes, actual))
if actual < rcvbuf_bytes * 2:
say("UDP buffer capped by kernel; check net.core.rmem_max before tuning")
threading.Thread(target=self._loop, daemon=True, name="bbhost-stun").start()
say("STUN + relay on udp {}:{} (relay ports {}-{}, players reach it at {})".format(
bind, port, OPTS["relay_ports"][0], OPTS["relay_ports"][1], OPTS["public_ip"] or "?"))
def _send_packet(self, data, addr):
"""Exactly the original sendto(data, addr), with counters only."""
try:
result = self.sock.sendto(data, addr)
except OSError:
self.send_errors += 1
raise
self.datagrams_out += 1
self.bytes_out += result
return result
def counters(self):
"""Cumulative traffic counters for read-only periodic diagnostics."""
return {k: getattr(self, k) for k in (
"datagrams_in", "bytes_in", "datagrams_out", "bytes_out",
"send_errors", "unrecognized_in", "frames", "shad_frames")}
def expire(self, now):
with LOCK:
for port, slot in list(self.ports.items()):
@@ -669,7 +713,13 @@ class Relay:
try:
data, addr = self.sock.recvfrom(65536)
except OSError:
# Do not spin at 100% CPU after a socket is closed.
if self.sock.fileno() < 0:
return
time.sleep(0.01)
continue
self.datagrams_in += 1
self.bytes_in += len(data)
try:
if data[:4] == SHAD_MAGIC:
self._shad(data, addr)
@@ -677,6 +727,8 @@ class Relay:
self._forward(data, addr)
elif len(data) >= 20 and data[0:2] == b"\x00\x01":
self._binding(data, addr)
else:
self.unrecognized_in += 1
except Exception as e: # one bad datagram must not stop the relay
say("stun/relay error from {}: {!r}".format(addr, e))
@@ -692,7 +744,7 @@ class Relay:
target = self.ports.get(dst)
to = target and target.get("addr")
if to:
self.sock.sendto(bytes([0xFB, ord("r")]) + struct.pack("!H", src_port) + data[12:], to)
self._send_packet(bytes([0xFB, ord("r")]) + struct.pack("!H", src_port) + data[12:], to)
self.frames += 1
# shadPS4 P2P relay (BBSC fork, 2026-10-12). Players whose routers or CGNAT stop direct P2P
@@ -713,7 +765,7 @@ class Relay:
if old is None or old["addr"] != addr:
say("shadPS4 relay: {} registered from {}:{}".format(
npid.rstrip(b"\0").decode("ascii", "replace"), addr[0], addr[1]))
self.sock.sendto(SHAD_MAGIC + b"\x02", addr)
self._send_packet(SHAD_MAGIC + b"\x02", addr)
elif kind == 0x03 and len(data) >= 37:
dst, src = data[5:21], data[21:37]
with LOCK:
@@ -732,11 +784,11 @@ class Relay:
tell, logged = now - last[0] >= 10, now - last[1] >= 60
self.shad_missing[key] = (now if tell else last[0], now if logged else last[1])
if to:
self.sock.sendto(SHAD_MAGIC + b"\x04" + src + data[37:], to)
self._send_packet(SHAD_MAGIC + b"\x04" + src + data[37:], to)
self.shad_frames += 1
else:
if tell:
self.sock.sendto(SHAD_MAGIC + b"\x05" + dst, addr)
self._send_packet(SHAD_MAGIC + b"\x05" + dst, addr)
if logged:
say("shadPS4 relay: {} -> {} dropped - {} is not on the relay (older emulator?)".format(
src.rstrip(b"\0").decode("ascii", "replace"),
@@ -773,7 +825,7 @@ class Relay:
mapped = (ip, port)
value = struct.pack("!BBH", 0, 1, mapped[1]) + socket.inet_aton(mapped[0])
attrs = struct.pack("!HH", 0x0001, len(value)) + value + attrs
self.sock.sendto(struct.pack("!HH", 0x0101, len(attrs)) + txid + attrs, addr)
self._send_packet(struct.pack("!HH", 0x0101, len(attrs)) + txid + attrs, addr)
RELAY = Relay()
+527 -43
View File
@@ -24,6 +24,7 @@ it runs on a stock Python, and it can retune the summon parameters on the way ou
"""
import argparse
import base64
import collections
import datetime
import hashlib
import json
@@ -239,6 +240,25 @@ MESSAGES_PER_REGION = 100 # newest kept per region
TOMBS_PER_REGION = 40
TOMBS_PER_USER_REGION = 3 # a player's older bloodstains in the same region drop out
TOMB_MAX_AGE = 7 * 24 * 3600
# Reply size cap (2026-10-18). Reports (valriv55, Gascoigne / Cathedral Ward, host and guest, solo
# too): "[Dantelion2 Panic] (null)(265) Transaction failed" every time right after a
# tomb_messenger/message_area reply of ~155 KB; replies of 97 KB / 106 KB never crashed.
# Lists are cut, newest first, so the whole reply stays under this many bytes of JSON.
# --tomb-max-response-bytes (SeamlessHunt's flag, 2026-10-17) sets it for messages and bloodstains;
# 0 there = no cap. Without the flag the 64 KB default applies.
AREA_REPLY_BUDGET = 64 * 1024
def _within_budget(entries, budget=None):
budget = AREA_REPLY_BUDGET if budget is None else budget
if not budget:
return list(entries)
kept, size = [], 512 # room for the reply's other fields
for entry in entries:
size += len(json.dumps(entry, separators=(",", ":"))) + 1
if size > budget:
break
kept.append(entry)
return kept
def _as_int(value, default=0):
try:
@@ -367,6 +387,10 @@ def blood_area(req):
here = [m for m in messages if _region(m) == key and m["UserId"] != user]
here.sort(key=lambda m: m["BloodMessId"], reverse=True)
found += [_message_entry(m) for m in here[:limit]]
total = len(found)
found = _within_budget(found)
if len(found) < total:
print(" messages: reply cut to {} of {} (size cap)".format(len(found), total), flush=True)
areas = ["{}/{}".format(*_region(a)[:2]) for a in body.get("AreaInfoList") or []]
stored = sorted({"{}/{}".format(m["AreaId"], m["AreaRegionId"]) for m in messages})
print(" messages: {} for user {} (asked {}; stored in {})".format(
@@ -488,6 +512,11 @@ def tomb_area(req):
here = [t for t in tombs if _region(t) == key and t["UserId"] != user]
here.sort(key=lambda t: t["TombMessId"], reverse=True)
found += [_tomb_entry(t) for t in here[:limit]]
total = len(found)
found = _within_budget(found)
if len(found) < total:
print(" bloodstains: reply cut to {} of {} (size cap)".format(len(found), total),
flush=True)
areas = ["{}/{}".format(*_region(a)[:2]) for a in body.get("AreaInfoList") or []]
print(" bloodstains: {} for user {} (asked {}; {} stored)".format(
len(found), user, ",".join(areas) or "-", len(tombs)), flush=True)
@@ -527,16 +556,42 @@ def tomb_delete(req):
# (FormDataVersion, FormData, ChannelId, ShareLevel, Status, DiscernmentWord, RitualLevel,
# HolyGrailTypeId, FixedOrGeneral, SubFeatureFlag, CreateUserId, CreateCharaId, CreateDate,
# LastPlayDate, TurnoutLevel, WishMaterialList); the reply parser 0x01E7F240 reads ChannelId and
# DiscernmentWord at the top level of an upload / share reply. Search (used by the ritual itself,
# GetCount 1) stays empty on purpose: answering it could swap a player's freshly made dungeon for
# someone else's. Unknown ids are never reported in LostChannelIdList (dungeons made before this
# server stored them must not be dropped from anyone's save).
# DiscernmentWord at the top level of an upload / share reply. Search answers only the fixed
# dungeons (FIXED_CHALICES below); any other search stays empty on purpose: answering it could swap
# a player's freshly made dungeon for someone else's. Unknown ids are never reported in
# LostChannelIdList (dungeons made before this server stored them must not be dropped from anyone's
# save).
GLYPH_LETTERS = "abcdefghijkmnpqrstuvwxyz23456789"
CHANNEL_SKIP = ("SessionId", "MessageId", "UserId", "CharaId")
# keys the server owns: an upload never overwrites them
CHANNEL_SERVER_KEYS = ("ChannelId", "DiscernmentWord", "CreateUserId", "CreateCharaId",
"CreateDate", "LastPlayDate")
# Fixed chalice dungeons (2026-10-19). The altar ritual of a FIXED chalice (Pthumeru, Hintertomb,
# Loran, Isz) asks /channel/search for its dungeon: FixedOrGeneralList [{"FixedOrGeneral": 2}],
# HolyGrailTypeId, RitualLevel, SubFeatureFlag 256, GetCount 1 (server log 2026-09-29 00:17:07,
# Pthumeru level 1; that player was back at the login 18 s later). This server answered with an
# empty list, so the ritual had no dungeon. The ten fixed dungeons' FormData is from
# NoDreamForTheHunterBB by Fahad (github.com/Mob100percent/NoDreamForTheHunterBB, GPL-3.0), taken
# from thehuntersdream.com. Each becomes one shared channel here (made at its first search, owner 0
# so no player's share / upload changes it), so every player gets the same ChannelId and glyph.
# Every entry: SubFeatureFlag 256, FixedOrGeneral 2, FormDataVersion 0, ShareLevel 2, Status 1,
# UnlockFlagList [0, <bit>, 0] (the bit the game sends in UnlockedFlagList), no WishMaterialList.
FIXED_CHALICE_SUB_FEATURE = 256
FIXED_CHALICES = [
# (HolyGrailTypeId, RitualLevel, unlock bit, FormData)
(0, 1, 1, "BQAAAIwAAAAAWgod3RcAAP////////////////////8+pk47WgAAAP////////////////////+g3UY7LQAAAP///////////////////////////////y0AAAAAAAAAAAAAAAAAAAAtAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="),
(0, 2, 128, "BQAAAIwAAAAAWhQdQRgAAP////////////////////8+pk47WgAAAEimTjtbAAAA//////////+g3UY7LQAAAP///////////////////////////////y0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="),
(0, 3, 16384, "BQAAAIwAAAAAWh4dpRgAAKBHXzs9AAAAoOaDO1UAAAA+pk47WgAAAEimTjtbAAAA//////////+g3UY7LQAAAP///////////////////////////////y0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="),
(0, 4, 2097152, "BQAAAIwAAAAAWigdCRkAAEDOYDs+AAAAQANtO0YAAAA+pk47WgAAAEimTjtbAAAAXKZOO10AAACg3UY7LQAAAP///////////////////////////////y0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="),
(0, 5, 268435456, "BQAAAIwAAAAAWjIdbRkAAEDOYDs+AAAA4POGO1cAAAA+pk47WgAAAEimTjtbAAAAXKZOO10AAACg3UY7LQAAAP///////////////////////////////y0AAAAAAAAAAAAAAAAAAAAtAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="),
(1, 2, 256, "BQAAAIwAAAAAWhUdSxgAAP////////////////////+spk47XgAAAMB1ETsKAAAA//////////+g3UY7LQAAAP///////////////////////////////y0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="),
(1, 3, 32768, "BQAAAIwAAAAAWh8drxgAAP//////////QG2FO1YAAACspk47XgAAALamTjtfAAAAwHUROwoAAACg3UY7LQAAAP///////////////////////////////y0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="),
(2, 4, 8388608, "BQAAAIwAAAAAWiodHRkAAP////////////////////8kp047YAAAAP////////////////////+g3UY7LQAAAP///////////////////////////////y0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="),
(2, 5, 1073741824, "BQAAAIwAAAAAWjQdgRkAAP//////////gHqIO1gAAAAkp047YAAAAC6nTjthAAAA//////////+g3UY7LQAAAP///////////////////////////////y0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="),
(3, 5, 2147483648, "BQAAAIwAAAAAWjUdixkAAD6mTjtaAAAAIAGKO1kAAACSp047YgAAAEimTjtbAAAArKZOO14AAACg3UY7LQAAAP///////////////////////////////y0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="),
]
def _date(when):
return time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(when))
@@ -635,7 +690,64 @@ def channel_word_search(req):
found = [_channel_entry(c) for c in STORE.data["channels"].values()
if word and c.get("DiscernmentWord") == word]
print(" chalice: glyph search '{}' -> {} found".format(word, len(found)), flush=True)
return empty("ChannelWordSearchResponse", ChannelList=found)
# Both shapes: NoDreamForTheHunterBB answers a glyph search with the dungeon's fields at the top
# level, this server used a ChannelList; which one the game reads is not checked yet. Unread keys
# are ignored by the game's parser.
return empty("ChannelWordSearchResponse", ChannelList=found, **(found[0] if found else {}))
def _fixed_channel(grail, level, bit, form_data):
"""The shared channel of one fixed chalice dungeon, made on first use. Call with STORE.lock."""
channels = STORE.data["channels"]
key = "{}-{}".format(grail, level)
known = channels.get(str(STORE.data.setdefault("fixed_channels", {}).get(key)))
if known is not None:
return known, False
channel_id = STORE.next_id()
channel = {"ChannelId": channel_id, "_created": time.time(), "_played": time.time(),
"DiscernmentWord": _new_glyph({c.get("DiscernmentWord") for c in channels.values()}),
"CreateUserId": 0, "CreateCharaId": 0,
"FixedOrGeneral": 2, "FormData": form_data, "FormDataVersion": 0,
"HolyGrailTypeId": grail, "RitualLevel": level,
"SubFeatureFlag": FIXED_CHALICE_SUB_FEATURE, "ShareLevel": 2, "Status": 1,
"TurnoutLevel": 0, "WishMaterialList": [],
"UnlockFlagList": [{"UnlockFlag": 0}, {"UnlockFlag": bit}, {"UnlockFlag": 0}]}
channels[str(channel_id)] = channel
STORE.data["fixed_channels"][key] = channel_id
return channel, True
def channel_search(req):
body = _request(req)
kinds = {_as_int(x.get("FixedOrGeneral"), -1) for x in body.get("FixedOrGeneralList") or []
if isinstance(x, dict)}
# a field the game leaves out or sends as null does not narrow the search
wanted = {key: _as_int(body[key], -1) for key in ("HolyGrailTypeId", "RitualLevel",
"SubFeatureFlag", "Status")
if body.get(key) is not None}
limit = max(_as_int(body.get("GetCount"), 1), 1)
found, made = [], []
if 2 in kinds:
with STORE.lock:
for grail, level, bit, form_data in FIXED_CHALICES:
have = {"HolyGrailTypeId": grail, "RitualLevel": level,
"SubFeatureFlag": FIXED_CHALICE_SUB_FEATURE, "Status": 1}
if any(have[key] != value for key, value in wanted.items()):
continue
channel, new = _fixed_channel(grail, level, bit, form_data)
if new:
made.append(channel["ChannelId"])
found.append(_channel_entry(channel))
if len(found) >= limit:
break
if made:
STORE.save()
print(" chalice: search {} {} -> {}{}".format(
sorted(kinds), wanted,
", ".join("fixed dungeon {} glyph {}".format(c["ChannelId"], c["DiscernmentWord"])
for c in found) or "nothing (only fixed dungeons are answered)",
" (new: {})".format(made) if made else ""), flush=True)
return empty("ChannelSearchResponse", ChannelList=found)
# Server notice (welcome message): server-notice.txt next to the messages file (--data). First line =
@@ -651,7 +763,11 @@ def normal_notice(req):
title, text = lines[0].strip(), "\n".join(lines[1:]).strip()
if title or text:
stamp = int(os.path.getmtime(path))
notices.append({"NoticeId": stamp, "Title": title, "Notice": text,
# Id + base64 text: confirmed by a server owner (2026-10-16) - with NoticeId and plain
# text the game showed "There is no new information."
notices.append({"Id": stamp, "NoticeId": stamp,
"Title": base64.b64encode(title.encode("utf-8")).decode("ascii"),
"Notice": base64.b64encode(text.encode("utf-8")).decode("ascii"),
"CheckTime": stamp})
except OSError:
pass
@@ -696,10 +812,10 @@ ROUTES = {
"/chair_messenger/get": lambda req: empty("ChairMessGetListResponse", ChairMessCount=0),
"/chair_messenger/update": lambda req: empty("ChairMessRespawnPointNoticeResponse"),
# chalice dungeon glyphs (channels), shared; search stays empty (see channel_upload)
# chalice dungeon glyphs (channels), shared; search answers only the fixed dungeons
"/channel/upload": channel_upload,
"/channel/share": channel_share,
"/channel/search": lambda req: empty("ChannelSearchResponse"),
"/channel/search": channel_search,
"/channel/word_search": channel_word_search,
"/channel/get_details_info": channel_get_details,
"/channel/get_info": channel_get_info,
@@ -824,26 +940,84 @@ class Handler(BaseHTTPRequestHandler):
do_GET = do_POST = do_PUT = do_DELETE = do_HEAD = _handle
class Tee:
"""Everything printed goes to the console window and to the log file."""
"""Everything printed goes to the log file at once and to the console window through a queue.
def __init__(self, *streams):
self.streams = streams
2026-10-20 (Eryk's server, 17:19:22-17:21:26): nothing was answered for two minutes while
shadNet ran on, and both players' games hung waiting. Every request prints before it replies,
and a paused console window (a click into it - Windows QuickEdit - stops every write until a
key is pressed) stopped every one of them. The console now has its own writer thread and a
bounded queue, so a paused window only delays the console; the log file keeps every line.
"""
CONSOLE_QUEUE_MAX = 5000
def __init__(self, console, log):
self.console = console
self.log = log
self.lock = threading.Lock()
self.queue = collections.deque()
self.dropped = 0
self.wake = threading.Event()
threading.Thread(target=self._console_writer, daemon=True).start()
def write(self, text):
for stream in self.streams:
with self.lock:
try:
stream.write(text)
self.log.write(text)
except Exception:
pass
if len(self.queue) < self.CONSOLE_QUEUE_MAX:
self.queue.append(text)
else:
self.dropped += 1
self.wake.set()
return len(text)
def flush(self):
for stream in self.streams:
with self.lock:
try:
stream.flush()
self.log.flush()
except Exception:
pass
def _console_writer(self):
while True:
self.wake.wait()
with self.lock:
text = "".join(self.queue)
self.queue.clear()
dropped, self.dropped = self.dropped, 0
self.wake.clear()
if dropped:
text += (" (console: {} line(s) not shown while this window was paused - they "
"are in the log file)\n".format(dropped))
if self.console is None or not text:
continue
try:
self.console.write(text)
self.console.flush()
except Exception:
pass
def disable_quick_edit():
"""Windows: a click into the console window must not pause the server (QuickEdit off).
Text can still be selected through the window menu (Edit > Mark)."""
if os.name != "nt":
return
try:
import ctypes
kernel32 = ctypes.windll.kernel32
handle = kernel32.GetStdHandle(-10) # STD_INPUT_HANDLE
mode = ctypes.c_uint32()
if kernel32.GetConsoleMode(handle, ctypes.byref(mode)) and mode.value & 0x0040:
# clear ENABLE_QUICK_EDIT_MODE (0x40), keep ENABLE_EXTENDED_FLAGS (0x80) so it sticks
kernel32.SetConsoleMode(handle, (mode.value & ~0x0040) | 0x0080)
print("console: click-to-select (QuickEdit) is off - a click in this window no longer "
"pauses the server")
except Exception:
pass
def open_log(log_dir):
os.makedirs(log_dir, exist_ok=True)
@@ -865,62 +1039,320 @@ def open_log(log_dir):
import socket
import struct
RELAY_CLIENTS = {}
RELAY_CLIENTS = {} # conn -> RelayClient
RELAY_LOCK = threading.Lock()
# Immutable snapshot rebuilt on connects/disconnects, not on every frame.
# Keep connection keys, so the sender exclusion remains exactly "is not conn".
RELAY_ROSTER = ()
RELAY_QUEUE_LIMIT = 512 * 1024 # bytes waiting for one player before he is dropped as too slow
# Stage 3A: coalesce ALREADY-QUEUED frames for each client into bounded TCP writes.
# No queue wait is introduced. Wire bytes, per-peer FIFO order and recipient list do not change.
RELAY_WRITE_BATCH_MAX_BYTES = 64 * 1024
RELAY_WRITE_BATCH_MAX_FRAMES = 64
# Share a single immutable encoded frame across recipient queues. With
# --relay-session-routing, high-rate session-state packets are sent only to the
# host and guests that advertised the same session in the existing 0x40 envelope.
# Discovery/follow packets and old or malformed frames retain global delivery.
RELAY_STATS_LOCK = threading.Lock()
RELAY_STATS = collections.Counter()
# Index of currently recorded session hints, protected by RELAY_LOCK.
RELAY_HINT_COUNTS = collections.Counter()
RELAY_HINTED_CLIENTS = 0
RELAY_SESSION_TYPES = frozenset((0x22, 0x23, 0x24, 0x25, 0x26, 0x27))
RELAY_DISCOVERY_TYPES = frozenset((0x20, 0x30))
def _relay_send(conn, payload):
conn.sendall(struct.pack("<I", len(payload)) + payload)
def _relay_stats_enabled():
return bool(getattr(ARGS, "relay_tcp_stats_interval", 0) > 0)
def _relay_parse_envelope(payload):
"""Untrusted observational hint, NOT an authentication/permission check.
Client format: SHAD 0x40, sender uint32 LE, host-session uint32 LE,
nested SHAD plus inner packet type. Older/malformed frames stay global.
"""
if (len(payload) < 18 or payload[:5] != b"SHAD\x40" or
payload[13:17] != b"SHAD"):
return None
sender, session = struct.unpack_from("<II", payload, 5)
if not sender:
return None
return sender, session, payload[17]
def _relay_record(**values):
with RELAY_STATS_LOCK:
RELAY_STATS.update(values)
def _relay_snapshot():
with RELAY_STATS_LOCK:
return RELAY_STATS.copy()
def _relay_log_stats(interval):
previous = _relay_snapshot()
while True:
time.sleep(interval)
now = _relay_snapshot()
delta = now.copy()
delta.subtract(previous)
previous = now
with RELAY_LOCK:
clients = len(RELAY_CLIENTS)
print(("bbsc TCP relay stats {}s: clients={}; rx_frames={} / {} bytes; "
"enqueued_frames={} / {} bytes; fanout={:.2f}; "
"observed_session_frames={}; discovery_frames={}; opaque_frames={}; "
"shadow_would_skip={} / {} bytes; slow_disconnects={}; send_errors={}").format(
interval, clients, delta["rx_frames"], delta["rx_bytes"],
delta["queued_frames"], delta["queued_bytes"],
delta["queued_frames"] / max(delta["rx_frames"], 1),
delta["session_frames"], delta["discovery_frames"],
delta["opaque_frames"], delta["shadow_skip_frames"],
delta["shadow_skip_bytes"], delta["slow_disconnects"],
delta["send_errors"]), flush=True)
def _relay_register_client(conn, client):
"""Register a socket, refreshing the snapshot and observational hints."""
global RELAY_ROSTER, RELAY_HINTED_CLIENTS
with RELAY_LOCK:
prior = RELAY_CLIENTS.get(conn)
if prior is not None and prior.session_hint is not None:
RELAY_HINT_COUNTS[prior.session_hint] -= 1
RELAY_HINTED_CLIENTS -= 1
if not RELAY_HINT_COUNTS[prior.session_hint]:
del RELAY_HINT_COUNTS[prior.session_hint]
RELAY_CLIENTS[conn] = client
if client.session_hint is not None:
RELAY_HINT_COUNTS[client.session_hint] += 1
RELAY_HINTED_CLIENTS += 1
RELAY_ROSTER = tuple(RELAY_CLIENTS.items())
return len(RELAY_CLIENTS)
def _relay_unregister_client(conn):
"""Drop a socket, including its monitoring-only session-hint entry."""
global RELAY_ROSTER, RELAY_HINTED_CLIENTS
with RELAY_LOCK:
old_client = RELAY_CLIENTS.pop(conn, None)
if old_client is not None and old_client.session_hint is not None:
RELAY_HINT_COUNTS[old_client.session_hint] -= 1
RELAY_HINTED_CLIENTS -= 1
if RELAY_HINT_COUNTS[old_client.session_hint] <= 0:
del RELAY_HINT_COUNTS[old_client.session_hint]
RELAY_ROSTER = tuple(RELAY_CLIENTS.items())
return len(RELAY_CLIENTS)
def _relay_forward(conn, payload):
"""Forward one unchanged frame globally or to its advertised session.
Session hints are routing hints, not authentication. They only narrow who
receives ordinary state frames; discovery, follow and opaque traffic remain
global for compatibility with joining and older clients.
"""
global RELAY_HINTED_CLIENTS
observe = _relay_stats_enabled()
skip_count = 0
category = "opaque_frames"
routing = bool(getattr(ARGS, "relay_session_routing", False))
envelope = _relay_parse_envelope(payload)
with RELAY_LOCK:
# Atomic reference to the old-style membership snapshot, preserving
# insertion order and socket identities without per-frame list copies.
roster = RELAY_ROSTER
sender_client = RELAY_CLIENTS.get(conn)
if envelope is not None and sender_client is not None and (observe or routing):
sender, session, kind = envelope
sender_client.sender_hint = sender
sender_client.seen_envelope = True
old_hint = sender_client.session_hint
new_hint = session or None
if new_hint != old_hint:
if old_hint is not None:
RELAY_HINT_COUNTS[old_hint] -= 1
RELAY_HINTED_CLIENTS -= 1
if RELAY_HINT_COUNTS[old_hint] <= 0:
del RELAY_HINT_COUNTS[old_hint]
if new_hint is not None:
RELAY_HINT_COUNTS[new_hint] += 1
RELAY_HINTED_CLIENTS += 1
sender_client.session_hint = new_hint
if kind in RELAY_DISCOVERY_TYPES:
category = "discovery_frames"
elif session and kind in RELAY_SESSION_TYPES:
category = "session_frames"
# Sender has just been indexed under this session, so
# counting all other known sessions excludes the sender.
skip_count = RELAY_HINTED_CLIENTS - RELAY_HINT_COUNTS.get(session, 0)
if routing:
# The host identifies itself with sender == session; guests
# advertise session == host. A modern client that has sent a
# valid envelope but still reports session zero keeps receiving
# early snapshots until it advertises a nonzero party. Completely
# silent/legacy sockets are not flooded with every world's state.
roster = tuple(
(peer_conn, peer) for peer_conn, peer in roster
if (peer_conn is conn or
(peer.seen_envelope and peer.session_hint is None) or
peer.session_hint == session or
peer.sender_hint == session))
# The encoded bytes and per-recipient FIFO ordering remain unchanged.
frame = struct.pack("<I", len(payload)) + payload if roster else b""
queued = 0
for peer_conn, client in roster:
if peer_conn is not conn:
queued += client.send_framed(frame)
if observe:
_relay_record(rx_frames=1, rx_bytes=4 + len(payload),
queued_frames=queued, queued_bytes=queued * (4 + len(payload)),
shadow_skip_frames=skip_count,
shadow_skip_bytes=skip_count * (4 + len(payload)),
**{category: 1})
return queued
class RelayClient:
"""One player's outgoing frames (2026-10-13). Each player has his own writer thread, so a
player whose game stopped reading (crash, dead link) can no longer stall the reader of everyone
else: before, the sender's thread blocked in sendall to him, stopped reading the sender, and
the sender's game dropped the link ~10 s later - every 10 s while hosting."""
def __init__(self, conn, name):
self.conn = conn
self.name = name
self.queue = collections.deque()
self.queued = 0
self.cond = threading.Condition()
self.closed = False
self.reason = ""
self.sender_hint = None
self.session_hint = None
self.seen_envelope = False
threading.Thread(target=self._writer, daemon=True).start()
def send(self, payload):
# Retain original API for peer-count (0x7f) announcements.
return self.send_framed(struct.pack("<I", len(payload)) + payload)
def send_framed(self, data):
"""Queue already length-prefixed immutable bytes; return accepted/not accepted."""
with self.cond:
if self.closed:
return False
if self.queued + len(data) > RELAY_QUEUE_LIMIT:
self._fail_locked("too slow ({} bytes waiting)".format(self.queued))
return False
was_empty = not self.queue
self.queue.append(data)
self.queued += len(data)
# A sleeping writer can exist only while the queue is empty.
if was_empty:
self.cond.notify()
return True
def _fail_locked(self, reason):
if not self.reason:
self.reason = reason
if _relay_stats_enabled():
if reason.startswith("too slow"):
_relay_record(slow_disconnects=1)
elif reason.startswith("send failed"):
_relay_record(send_errors=1)
self.closed = True
self.queue.clear()
self.queued = 0
self.cond.notify()
try:
self.conn.shutdown(socket.SHUT_RDWR) # ends his reader too
except OSError:
pass
def fail(self, reason):
with self.cond:
self._fail_locked(reason)
def _writer(self):
while True:
with self.cond:
while not self.queue and not self.closed:
self.cond.wait()
if self.closed:
return
# Drain only frames already waiting. This adds no artificial
# latency and leaves the same 512-KiB enqueue/backpressure rule.
data = self.queue.popleft()
self.queued -= len(data)
# The common case is a single frame with an otherwise empty
# queue: avoid allocating a list or copying its bytes.
if self.queue and len(data) < RELAY_WRITE_BATCH_MAX_BYTES:
batch = [data]
size = len(data)
while (self.queue and len(batch) < RELAY_WRITE_BATCH_MAX_FRAMES and
size + len(self.queue[0]) <= RELAY_WRITE_BATCH_MAX_BYTES):
frame = self.queue.popleft()
batch.append(frame)
size += len(frame)
self.queued -= len(frame)
if len(batch) > 1:
data = b"".join(batch)
# Preserve the exact concatenated length-prefixed TCP stream.
try:
self.conn.sendall(data)
except OSError as error:
self.fail("send failed ({})".format(error))
return
def _relay_announce():
with RELAY_LOCK:
clients = list(RELAY_CLIENTS.items())
for conn, _ in clients:
try:
_relay_send(conn, bytes([0x7F, min(len(clients) - 1, 255)]))
except OSError:
pass
clients = list(RELAY_CLIENTS.values())
for client in clients:
client.send(bytes([0x7F, min(len(clients) - 1, 255)]))
def _relay_client(conn, address):
name = "{}:{}".format(*address)
with RELAY_LOCK:
RELAY_CLIENTS[conn] = name
print(" relay: {} connected ({} player(s))".format(name, len(RELAY_CLIENTS)), flush=True)
client = RelayClient(conn, name)
count = _relay_register_client(conn, client)
print(" relay: {} connected ({} player(s))".format(name, count), flush=True)
_relay_announce()
frames = 0
reason = ""
started = time.time()
try:
while True:
header = _recv_exact(conn, 4)
if header is None:
reason = "closed by the player"
break
size = struct.unpack("<I", header)[0]
if size > 64 * 1024:
reason = "oversized frame ({} bytes)".format(size)
break
payload = _recv_exact(conn, size) if size else b""
if payload is None:
reason = "closed by the player"
break
frames += 1
with RELAY_LOCK:
others = [c for c in RELAY_CLIENTS if c is not conn]
for other in others:
try:
_relay_send(other, payload)
except OSError:
pass
except OSError:
pass
_relay_forward(conn, payload)
except OSError as error:
reason = "read failed ({})".format(error)
finally:
with RELAY_LOCK:
RELAY_CLIENTS.pop(conn, None)
left = len(RELAY_CLIENTS)
left = _relay_unregister_client(conn)
if client.reason:
reason = client.reason # the writer gave up first
client.fail(reason or "gone")
try:
conn.close()
except OSError:
pass
print(" relay: {} left after {} frames ({} player(s))".format(name, frames, left),
flush=True)
print(" relay: {} left after {} frames, {:.0f} s ({}) ({} player(s))".format(
name, frames, time.time() - started, reason, left), flush=True)
_relay_announce()
@@ -944,10 +1376,18 @@ def start_relay(host, port):
while True:
conn, address = listener.accept()
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
conn.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
if hasattr(socket, "TCP_USER_TIMEOUT"): # Linux: unacked data for 20 s = dead link
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_USER_TIMEOUT, 20000)
threading.Thread(target=_relay_client, args=(conn, address), daemon=True).start()
threading.Thread(target=accept_loop, daemon=True).start()
print("BBSC relay on {}:{} (for the BBSC mod)".format(host, port))
print("BBSC relay on {}:{} (for the BBSC mod); immutable frame reuse enabled".format(
host, port))
interval = getattr(ARGS, "relay_tcp_stats_interval", 0)
if interval > 0:
threading.Thread(target=_relay_log_stats, args=(interval,), daemon=True,
name="bbsc-tcp-stats").start()
class QuietHTTPServer(ThreadingHTTPServer):
@@ -985,7 +1425,28 @@ def start_bbhost():
return
if not public:
print("bbhost: no public address (set --advertise or --bbhost-public-ip): relay off")
bbhost_service.RELAY.start(ARGS.bbhost_stun_bind or ARGS.host, ARGS.bbhost_stun_port)
bbhost_service.RELAY.start(ARGS.bbhost_stun_bind or ARGS.host, ARGS.bbhost_stun_port,
rcvbuf_bytes=ARGS.bbhost_udp_rcvbuf)
if ARGS.bbhost_udp_stats_interval:
interval = ARGS.bbhost_udp_stats_interval
if not 5 <= interval <= 3600:
raise ValueError("UDP stats interval must be 5..3600 seconds")
def log_udp_stats():
before = bbhost_service.RELAY.counters()
while True:
time.sleep(interval)
after = bbhost_service.RELAY.counters()
delta = {key: after[key] - before[key] for key in after}
before = after
print("bbhost UDP stats {}s: rx={} datagrams / {} bytes; tx={} datagrams / {} bytes; "
"shad forwarded={}; bbhost forwarded={}; send_errors={}; unrecognized={}".format(
interval, delta["datagrams_in"], delta["bytes_in"],
delta["datagrams_out"], delta["bytes_out"],
delta["shad_frames"], delta["frames"],
delta["send_errors"], delta["unrecognized_in"]), flush=True)
threading.Thread(target=log_udp_stats, daemon=True, name="bbhost-udp-stats").start()
def follow_address():
# a DuckDNS name can change address; the relay addresses must follow it
@@ -1001,6 +1462,7 @@ def start_bbhost():
def main():
global ARGS, SS_INFO_B64, SS_INFO_LOCAL_B64, SS_INFO_BBHOST_B64, SS_INFO_BBHOST_LOCAL_B64
global AREA_REPLY_BUDGET
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--shadnet", required=True,
@@ -1009,6 +1471,12 @@ def main():
parser.add_argument("--port", type=int, default=20443)
parser.add_argument("--relay-port", type=int, default=20444,
help="BBSC mod relay (TCP); 0 turns it off")
parser.add_argument("--relay-tcp-stats-interval", type=int, default=0,
help="TCP fan-out and untrusted-session SHADOW telemetry seconds; "
"0 disables statistics. Routing stays global.")
parser.add_argument("--relay-session-routing", action="store_true",
help="route high-rate BBSC state frames only within their advertised "
"host session; discovery/follow/legacy frames remain global")
parser.add_argument("--advertise",
help="address to put in ss.info if it differs from --host")
parser.add_argument("--summon-interval", type=int, default=0,
@@ -1017,6 +1485,9 @@ def main():
help="co-op level window (vanilla 20; try 999 to match at any level)")
parser.add_argument("--no-playlog", action="store_true",
help="turn off the play-log uploads to Amazon S3 (recommended)")
parser.add_argument("--tomb-max-response-bytes", type=int, default=None,
help="message / bloodstain reply budget in bytes (default 65536 - the "
"Dantelion2 Panic 265 fix); 0 = no cap")
parser.add_argument("--verbose", action="store_true", help="log headers and request bodies")
parser.add_argument("--no-bbhost", dest="bbhost", action="store_false",
help="turn off the bbhost players' services (bbhost_service.py)")
@@ -1024,6 +1495,10 @@ def main():
help="bbhost's session/account port (bbhost's default 18671; 0 = only --port)")
parser.add_argument("--bbhost-stun-port", type=int, default=3478,
help="bbhost STUN + relay, UDP (0 = off)")
parser.add_argument("--bbhost-udp-rcvbuf", type=int, default=0,
help="requested UDP SO_RCVBUF bytes; 0 leaves OS default (safe default)")
parser.add_argument("--bbhost-udp-stats-interval", type=int, default=0,
help="periodic UDP traffic counters, seconds; 0 disables logging")
parser.add_argument("--bbhost-stun-bind", default="",
help="address the STUN socket binds to (default: --host)")
parser.add_argument("--bbhost-public-ip", default="",
@@ -1040,10 +1515,19 @@ def main():
parser.add_argument("--data", default=os.path.join(data_home, "online-data.json"),
help="where shared messages and bloodstains are kept between runs")
ARGS = parser.parse_args()
if ARGS.tomb_max_response_bytes and not 4096 <= ARGS.tomb_max_response_bytes <= 2*1024*1024:
parser.error("--tomb-max-response-bytes must be 0 or 4096..2097152")
if ARGS.bbhost_udp_stats_interval and not 5 <= ARGS.bbhost_udp_stats_interval <= 3600:
parser.error("--bbhost-udp-stats-interval must be 0 or 5..3600")
if ARGS.bbhost_udp_rcvbuf and not 8192 <= ARGS.bbhost_udp_rcvbuf <= 64*1024*1024:
parser.error("--bbhost-udp-rcvbuf must be 0 or 8192..67108864")
if ARGS.tomb_max_response_bytes is not None:
AREA_REPLY_BUDGET = ARGS.tomb_max_response_bytes
log_path = open_log(ARGS.log_dir)
print("BBSC game server - {} - log: {}".format(
datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), log_path))
disable_quick_edit()
global STORE
STORE = OnlineStore(ARGS.data)