mirror of
https://github.com/CLSAMG22/BBSC.git
synced 2026-11-08 01:48:15 -10:00
Compare commits
1
Commits
v0.0.1_items
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f11faf88b |
No files matched your search
+3
-1
@@ -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
@@ -11,12 +11,13 @@ Support BBSC:
|
||||
|
||||
WORKSHOP
|
||||
--------
|
||||
(no one yet)
|
||||
aboood888848
|
||||
Prohorov Ilja
|
||||
|
||||
|
||||
HUNTER
|
||||
------
|
||||
(no one yet)
|
||||
.ζ͜长𐌏𐌕𐌏ᚱ𐌆
|
||||
|
||||
|
||||
LAMPLIGHT
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
+2662
-164
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user